22 Commits
Author SHA1 Message Date
DongHyeonkaandClaude Opus 5 2420fcee9f fix: 공개 문서가 실제로 그리는 주제 링크도 탐색 필터로 돌린다
주제 링크를 고쳤는데 화면은 그대로 `/topics/:slug` 로 갔다. 공개 문서의 머리말을 그리는
것은 `PublicDocumentHeader` 의 breadcrumb 이 아니라 렌더 모델의 `topic.publicPath` 이고,
같은 파일 안에서 두 자리가 같은 경로를 만들고 있었다. 한 자리만 고쳤으니 배포하고 눌러
보기 전까지는 고친 것처럼 보였다.

테스트도 이 링크를 묻지 않고 있었다. 머리말 검사가 `Case/JPA/Backend Skeleton` 이라는
글자만 확인해서, 그 글자가 어디로 가는지는 아무도 보지 않았다. 되돌려 보면 공개 문서
여섯 개가 모두 빨개진다.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0189NzCryfeqDzS81EWidnBx
2026-08-26 23:55:19 +09:00
DongHyeonkaandClaude Opus 5 2632850322 fix: 최근 기록에 Open Question 을 싣고, 주제 링크를 탐색 필터로 돌린다
게시한 질문이 홈 최근 기록에 나오지 않았다. 계약의 `LatestEntry.entryType` 이 네 값만
허용해 백엔드가 담을 수 없었다 — design-package ef49d3a 에서 `QUESTION` 을 더했고 여기서
계약을 다시 생성한다. 포트의 `LatestRecordEntry.entryType` 도 같이 넓히고, 홈의 표기는
목록의 다른 이름들과 같은 규칙(대문자에 공백)을 따라 `OPEN QUESTION` 으로 적는다.

문서 머리말의 주제 링크는 `/topics/:slug` 로 가고 있었는데, 그 화면은 `jpa`/`authentication`
/`redis` 세 개를 하드코딩해 두고 있어 실제 주제는 무엇이든 404 가 됐다. 게시한 모든 문서의
주제 링크가 거기로 갔다.

주제 페이지를 채우는 대신 링크를 탐색 필터로 돌린다. 지금 그 페이지만 줄 수 있는 것 —
설명, 범위, 선별한 대표 기록 — 이 전부 비어 있고, Studio 의 주제 만들기는 `{name, slug}` 만
보내므로 설명을 쓸 칸조차 없다. 독자가 거기서 기대하는 것은 같은 주제의 기록 목록이고,
그것은 `/explore?topic=` 이 그대로 준다. 주제가 여러 개가 되고 설명을 쓸 수 있게 되면 그때
페이지를 만드는 것이 순서다.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0189NzCryfeqDzS81EWidnBx
2026-08-26 23:44:20 +09:00
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
113 changed files with 3360 additions and 1980 deletions
+1
View File
@@ -29,3 +29,4 @@ artifacts/tests/visual/
# Git worktrees created inside the repository. A worktree is a checkout, not # Git worktrees created inside the repository. A worktree is a checkout, not
# source: committing one would nest a second working copy inside this one. # source: committing one would nest a second working copy inside this one.
.worktrees/ .worktrees/
.playwright-mcp/
@@ -1 +0,0 @@
[ 1019ms] [ERROR] Failed to load resource: the server responded with a status of 401 (Unauthorized) @ https://hyeonworks.com/api/v1/studio/session:0
@@ -1 +0,0 @@
[ 39361ms] [ERROR] Failed to load resource: the server responded with a status of 409 (Conflict) @ https://hyeonworks.com/api/v1/studio/cases/b09f168b-b205-478f-b60f-83872d1b4d01:0
@@ -1 +0,0 @@
[ 4651ms] [ERROR] Failed to load resource: the server responded with a status of 409 (Conflict) @ https://hyeonworks.com/api/v1/studio/cases/b09f168b-b205-478f-b60f-83872d1b4d01:0
@@ -1,16 +0,0 @@
- generic [ref=f1e3]:
- banner [ref=f1e4]:
- generic [ref=f1e5]: prod
- main [ref=f1e6]:
- heading "Sign in to your account" [level=1] [ref=f1e8]
- generic [ref=f1e12]:
- generic [ref=f1e13]:
- generic [ref=f1e14]: Username or email
- textbox "Username or email" [active] [ref=f1e17]
- generic [ref=f1e18]:
- generic [ref=f1e19]: Password
- generic [ref=f1e21]:
- textbox "Password" [ref=f1e24]
- button "Show password" [ref=f1e26] [cursor=pointer]:
- generic [ref=f1e27]:
- button "Sign In" [ref=f1e30] [cursor=pointer]
@@ -1,74 +0,0 @@
- generic [ref=f2e3]:
- link "본문으로 건너뛰기" [ref=f2e4] [cursor=pointer]:
- /url: "#main-content"
- banner [ref=f2e5]:
- generic [ref=f2e6]:
- link "TechLog 홈" [ref=f2e8] [cursor=pointer]:
- /url: /
- text: TechLog
- generic [ref=f2e9]:
- button "TechLog 검색 열기" [ref=f2e11] [cursor=pointer]: 검색
- group [ref=f2e12]:
- generic "메뉴" [ref=f2e13] [cursor=pointer]
- main [ref=f2e14]:
- region [ref=f2e15]:
- heading "TechLog" [level=1] [ref=f2e16]
- paragraph [ref=f2e17]: 문제를 재현하고 검증해 운영 가능한 설계로 연결합니다.
- region [ref=f2e18]:
- generic [ref=f2e19]:
- generic [ref=f2e20]:
- paragraph [ref=f2e21]: Index
- heading "최근 기록" [level=2] [ref=f2e22]
- link "모든 기록 탐색" [ref=f2e23] [cursor=pointer]:
- /url: /explore
- list [ref=f2e24]:
- listitem [ref=f2e25]:
- link "RELEASE 2026.08.21 첫 공개 공개 사이트와 Studio 작성 흐름을 처음으로 실제 서버에 올렸습니다. TechLog · TechLog" [ref=f2e26] [cursor=pointer]:
- /url: /releases/0.1.0
- generic [ref=f2e27]:
- generic [ref=f2e28]: RELEASE
- time [ref=f2e29]: 2026.08.21
- generic [ref=f2e30]:
- heading "첫 공개" [level=3] [ref=f2e31]
- paragraph [ref=f2e32]: 공개 사이트와 Studio 작성 흐름을 처음으로 실제 서버에 올렸습니다.
- paragraph [ref=f2e33]: TechLog · TechLog
- generic [ref=f2e34]:
- region [ref=f2e35]:
- generic [ref=f2e37]:
- paragraph [ref=f2e38]: Explore
- heading "어떤 맥락으로 읽을까요?" [level=2] [ref=f2e39]
- list [ref=f2e40]:
- listitem [ref=f2e41]:
- link "문제를 따라가며 검증 과정을 읽습니다 Case" [ref=f2e42] [cursor=pointer]:
- /url: /explore/cases
- generic [ref=f2e43]: 문제를 따라가며 검증 과정을 읽습니다
- strong [ref=f2e44]: Case
- generic [ref=f2e45]:
- listitem [ref=f2e46]:
- link "다시 찾을 수 있는 기술 기준을 확인합니다 Reference" [ref=f2e47] [cursor=pointer]:
- /url: /explore/references
- generic [ref=f2e48]: 다시 찾을 수 있는 기술 기준을 확인합니다
- strong [ref=f2e49]: Reference
- generic [ref=f2e50]:
- listitem [ref=f2e51]:
- link "아직 끝나지 않은 판단과 다음 검증을 봅니다 OpenQuestion" [ref=f2e52] [cursor=pointer]:
- /url: /explore/questions
- generic [ref=f2e53]: 아직 끝나지 않은 판단과 다음 검증을 봅니다
- strong [ref=f2e54]: OpenQuestion
- generic [ref=f2e55]:
- listitem [ref=f2e56]:
- link "여러 기록을 하나의 시스템 맥락에서 연결합니다 Project" [ref=f2e57] [cursor=pointer]:
- /url: /projects
- generic [ref=f2e58]: 여러 기록을 하나의 시스템 맥락에서 연결합니다
- strong [ref=f2e59]: Project
- generic [ref=f2e60]:
- contentinfo [ref=f2e61]:
- generic [ref=f2e62]:
- generic [ref=f2e63]:
- paragraph [ref=f2e64]: 동현
- paragraph [ref=f2e65]: 문제를 재현하고 검증해 운영 가능한 설계로 연결합니다.
- generic [ref=f2e66]:
- link "프로필" [ref=f2e67] [cursor=pointer]:
- /url: /profile
- link "변경 기록" [ref=f2e68] [cursor=pointer]:
- /url: /releases
@@ -1,100 +0,0 @@
- generic [ref=f3e22]:
- link "본문으로 건너뛰기" [ref=f3e23] [cursor=pointer]:
- /url: "#main-content"
- banner [ref=f3e24]:
- generic [ref=f3e25]:
- link "TechLog Studio" [ref=f3e26] [cursor=pointer]:
- /url: /studio
- text: TechLog
- generic [ref=f3e27]: Studio
- navigation "Studio 주 탐색" [ref=f3e29]:
- link "작업본" [ref=f3e30] [cursor=pointer]:
- /url: /studio/documents
- link "게시 기록" [ref=f3e31] [cursor=pointer]:
- /url: /studio/publications
- link "새 문서" [ref=f3e32] [cursor=pointer]:
- /url: /studio/documents/new
- link "주제·프로젝트" [ref=f3e33] [cursor=pointer]:
- /url: /studio/taxonomy
- link "릴리즈" [ref=f3e34] [cursor=pointer]:
- /url: /studio/releases
- link "공개 사이트 보기" [ref=f3e35] [cursor=pointer]:
- /url: /
- button "로그아웃" [ref=f3e36]
- main [ref=f3e37]:
- generic [ref=f3e38]:
- generic [ref=f3e39]:
- generic [ref=f3e40]:
- paragraph [ref=f3e41]: WORKING COPIES
- heading "작업본" [level=1] [ref=f3e42]
- paragraph [ref=f3e43]: 세션에 있는 Case, Reference, Question, Decision을 찾고 다음 작업으로 이동합니다.
- link "새 문서" [ref=f3e44] [cursor=pointer]:
- /url: /studio/documents/new
- region "작업본 검색과 필터" [ref=f3e45]:
- search [ref=f3e46]:
- generic [ref=f3e47]: 검색
- generic [ref=f3e48]:
- searchbox "검색" [ref=f3e49]
- button "검색" [ref=f3e50]
- generic [ref=f3e51]:
- text: 종류
- combobox "종류" [ref=f3e52]:
- option "전체" [selected]
- option "Case"
- option "Reference"
- option "Question"
- option "Decision"
- generic [ref=f3e53]:
- text: 상태
- combobox "상태" [ref=f3e54]:
- option "전체" [selected]
- option "게시 전"
- option "게시 중"
- option "게시 취소"
- paragraph [ref=f3e55]:
- generic [ref=f3e56]: 2개 표시 중
- alert [ref=f3e57]: 삭제하지 못했습니다. 게시 중이거나, 이 기록을 참조하는 곳이 있거나, 다른 곳에서 먼저 수정되었을 수 있습니다.
- generic [ref=f3e58]:
- article [ref=f3e1]:
- paragraph [ref=f3e2]: Case
- generic [ref=f3e3]:
- heading [level=2] [ref=f3e4]:
- link "게시 흐름 확인" [ref=f3e5] [cursor=pointer]:
- /url: /studio/documents/b09f168b-b205-478f-b60f-83872d1b4d01/edit
- paragraph [ref=f3e6]: 프로젝트 미지정
- generic [ref=f3e7]:
- generic [ref=f3e8]:
- term [ref=f3e9]: 상태
- definition [ref=f3e10]: 게시 취소
- generic [ref=f3e11]:
- term [ref=f3e12]: 다음
- definition [ref=f3e13]:
- link "검증하기" [ref=f3e14] [cursor=pointer]:
- /url: /studio/documents/b09f168b-b205-478f-b60f-83872d1b4d01/validation
- generic [ref=f3e15]:
- term [ref=f3e16]: 수정
- definition [ref=f3e17]:
- time [ref=f3e18]: 2026. 8. 21.
- button "삭제" [ref=f3e19]
- article [ref=f3e59]:
- paragraph [ref=f3e60]: Case
- generic [ref=f3e61]:
- heading [level=2] [ref=f3e62]:
- link "브라우저에서 토큰을 어디까지 다뤄야 하는가..?" [ref=f3e63] [cursor=pointer]:
- /url: /studio/documents/f363edc8-2c13-4995-acda-934237034a85/edit
- paragraph [ref=f3e64]: 프로젝트 미지정
- generic [ref=f3e65]:
- generic [ref=f3e66]:
- term [ref=f3e67]: 상태
- definition [ref=f3e68]: 게시 전
- generic [ref=f3e69]:
- term [ref=f3e70]: 다음
- definition [ref=f3e71]:
- link "검증하기" [ref=f3e72] [cursor=pointer]:
- /url: /studio/documents/f363edc8-2c13-4995-acda-934237034a85/validation
- generic [ref=f3e73]:
- term [ref=f3e74]: 수정
- definition [ref=f3e75]:
- time [ref=f3e76]: 2026. 8. 21.
- button "삭제" [ref=f3e77]
- paragraph [ref=f3e78]
@@ -1,55 +0,0 @@
- generic [ref=f4e3]:
- link "본문으로 건너뛰기" [ref=f4e4] [cursor=pointer]:
- /url: "#main-content"
- banner [ref=f4e5]:
- generic [ref=f4e6]:
- link "TechLog Studio" [ref=f4e7] [cursor=pointer]:
- /url: /studio
- text: TechLog
- generic [ref=f4e8]: Studio
- navigation "Studio 주 탐색" [ref=f4e10]:
- link "작업본" [ref=f4e11] [cursor=pointer]:
- /url: /studio/documents
- link "게시 기록" [ref=f4e12] [cursor=pointer]:
- /url: /studio/publications
- link "새 문서" [ref=f4e13] [cursor=pointer]:
- /url: /studio/documents/new
- link "주제·프로젝트" [ref=f4e14] [cursor=pointer]:
- /url: /studio/taxonomy
- link "릴리즈" [ref=f4e15] [cursor=pointer]:
- /url: /studio/releases
- link "공개 사이트 보기" [ref=f4e16] [cursor=pointer]:
- /url: /
- button "로그아웃" [ref=f4e17]
- main [ref=f4e18]:
- generic [ref=f4e19]:
- generic [ref=f4e20]:
- paragraph [ref=f4e21]: NEW WORKING COPY
- heading "새 문서" [level=1] [ref=f4e22]
- paragraph [ref=f4e23]: 목적에 맞는 기록 종류를 선택하면 빈 작업본을 만들고 바로 편집을 시작합니다.
- generic [ref=f4e24]:
- group "문서 종류" [ref=f4e25]:
- generic [ref=f4e27] [cursor=pointer]:
- radio "Case 문제를 재현하고 검증한 결론을 기록합니다. 문제 · 결론 · 환경 · 재현 · 본문" [checked] [active] [ref=f4e28]
- strong [ref=f4e29]: Case
- generic [ref=f4e30]: 문제를 재현하고 검증한 결론을 기록합니다.
- generic [ref=f4e31]: 문제 · 결론 · 환경 · 재현 · 본문
- generic [ref=f4e32] [cursor=pointer]:
- radio "Reference 반복해서 적용할 기술 기준을 정리합니다. 목적 · 규칙 · 적용 조건 · 예외 · 예시" [ref=f4e33]
- strong [ref=f4e34]: Reference
- generic [ref=f4e35]: 반복해서 적용할 기술 기준을 정리합니다.
- generic [ref=f4e36]: 목적 · 규칙 · 적용 조건 · 예외 · 예시
- generic [ref=f4e37] [cursor=pointer]:
- radio "Question 아직 닫히지 않은 판단과 다음 검증을 관리합니다. 상태 · 사실 · 가정 · 미지수 · 선택지" [ref=f4e38]
- strong [ref=f4e39]: Question
- generic [ref=f4e40]: 아직 닫히지 않은 판단과 다음 검증을 관리합니다.
- generic [ref=f4e41]: 상태 · 사실 · 가정 · 미지수 · 선택지
- generic [ref=f4e42] [cursor=pointer]:
- radio "Decision 프로젝트가 선택한 방향과 그 근거·영향을 기록합니다. 상태 · 결정일 · 결정문 · 판단 이유 · 영향 · 근거" [ref=f4e43]
- strong [ref=f4e44]: Decision
- generic [ref=f4e45]: 프로젝트가 선택한 방향과 그 근거·영향을 기록합니다.
- generic [ref=f4e46]: 상태 · 결정일 · 결정문 · 판단 이유 · 영향 · 근거
- generic [ref=f4e47]:
- button "작업본 만들기" [ref=f4e48]
- paragraph [ref=f4e49]: 이 화면의 작업본은 현재 Studio 세션에서만 유지됩니다.
- paragraph [ref=f4e50]
@@ -1,143 +0,0 @@
- generic [ref=f4e3]:
- link "본문으로 건너뛰기" [ref=f4e4] [cursor=pointer]:
- /url: "#main-content"
- banner [ref=f4e5]:
- generic [ref=f4e6]:
- link "TechLog Studio" [ref=f4e7] [cursor=pointer]:
- /url: /studio
- text: TechLog
- generic [ref=f4e8]: Studio
- navigation "Studio 주 탐색" [ref=f4e10]:
- link "작업본" [ref=f4e11] [cursor=pointer]:
- /url: /studio/documents
- link "게시 기록" [ref=f4e12] [cursor=pointer]:
- /url: /studio/publications
- link "새 문서" [ref=f4e13] [cursor=pointer]:
- /url: /studio/documents/new
- link "주제·프로젝트" [ref=f4e14] [cursor=pointer]:
- /url: /studio/taxonomy
- link "릴리즈" [ref=f4e15] [cursor=pointer]:
- /url: /studio/releases
- link "공개 사이트 보기" [ref=f4e16] [cursor=pointer]:
- /url: /
- button "로그아웃" [ref=f4e17]
- main [ref=f4e18]:
- generic [ref=f4e51]:
- tablist "문서 편집 화면" [ref=f4e52]:
- tab "편집" [selected] [ref=f4e53]
- tab "즉시 미리보기" [ref=f4e54]
- generic [ref=f4e55]:
- tabpanel "편집" [ref=f4e57]:
- generic [ref=f4e58]:
- paragraph [ref=f4e59]: CASE · VERSION 1
- heading "문서 편집" [level=1] [ref=f4e60]
- paragraph [ref=f4e61]: 제목 없는 작업본
- region [ref=f4e62]:
- generic [ref=f4e63]:
- paragraph [ref=f4e64]: DOCUMENT
- heading "기본 정보" [level=2] [ref=f4e65]
- generic [ref=f4e66]:
- generic [ref=f4e67]:
- generic [ref=f4e68]: 제목
- textbox "제목" [ref=f4e69]
- generic [ref=f4e70]:
- generic [ref=f4e71]: slug
- textbox "slug" [ref=f4e72]:
- /placeholder: 비우면 제목에서 만듭니다 (영문 소문자·숫자·하이픈)
- generic [ref=f4e73]:
- generic [ref=f4e74]: 요약
- textbox "요약" [ref=f4e75]
- generic [ref=f4e76]:
- generic [ref=f4e77]: Topic
- combobox "Topic" [ref=f4e78]:
- option "선택하지 않음" [selected]
- option "OAuth/OIDC 인증 경계"
- generic [ref=f4e79]:
- generic [ref=f4e80]: Project
- combobox "Project" [ref=f4e81]:
- option "미지정" [selected]
- option "Backend Clean Architecture"
- option "KeyCloak Patterns"
- option "Liner N + 1문제"
- group "관계" [ref=f4e82]:
- paragraph [ref=f4e84]: 연결한 공개 기록이 없습니다.
- button "관계 추가" [ref=f4e85]
- region [ref=f4e86]:
- generic [ref=f4e87]:
- paragraph [ref=f4e88]: CASE
- heading "문제와 검증" [level=2] [ref=f4e89]
- generic [ref=f4e90]:
- generic [ref=f4e91]:
- generic [ref=f4e92]: 문제
- textbox "문제" [ref=f4e93]
- generic [ref=f4e94]:
- generic [ref=f4e95]: 결론
- textbox "결론" [ref=f4e96]
- generic [ref=f4e97]:
- generic [ref=f4e98]: 검증 환경
- textbox "검증 환경" [ref=f4e99]
- generic [ref=f4e100]:
- generic [ref=f4e101]: 재현 조건
- textbox "재현 조건" [ref=f4e102]
- generic [ref=f4e103]:
- generic [ref=f4e104]: 마지막 검증일
- textbox "마지막 검증일" [ref=f4e105]
- generic [ref=f4e106]:
- generic [ref=f4e107]: 본문 Markdown
- textbox "본문 Markdown" [ref=f4e108]
- generic [ref=f4e109]:
- paragraph [ref=f4e110]: EVIDENCE
- heading "본문에 Asset 삽입" [level=3] [ref=f4e111]
- paragraph [ref=f4e112]: 목록에서 선택하면 본문 커서 위치에 evidence 구문을 삽입합니다. READY 상태의 Asset만 선택할 수 있습니다.
- generic [ref=f4e113]:
- generic [ref=f4e114]:
- generic [ref=f4e115]: 업로드 종류
- combobox "업로드 종류" [ref=f4e116]:
- option "이미지" [selected]
- option "다이어그램"
- option "첨부파일"
- button "Asset 업로드" [ref=f4e117]
- generic [ref=f4e118]:
- search [ref=f4e119]:
- generic [ref=f4e120]: Asset 검색
- generic [ref=f4e121]:
- searchbox "Asset 검색" [ref=f4e122]
- button "검색" [ref=f4e123]
- generic [ref=f4e124]:
- checkbox "삽입할 때 크게 보기 허용" [checked] [ref=f4e125]
- generic [ref=f4e126]: 삽입할 때 크게 보기 허용
- status [ref=f4e127]: 삽입할 수 있는 Asset 1개
- list [ref=f4e128]:
- listitem [ref=f4e129]:
- button "screenshot-from-2026-08-21-18-04-49-72f1f9c6" [ref=f4e130]
- button "삭제" [ref=f4e131]
- complementary [ref=f4e132]:
- paragraph [ref=f4e133]: WORKING COPY
- heading "작업 상태" [level=2] [ref=f4e134]
- status "편집 상태" [ref=f4e135]: 저장됨
- generic [ref=f4e136]:
- generic [ref=f4e137]:
- term [ref=f4e138]: 저장 버전
- definition [ref=f4e139]: "1"
- generic [ref=f4e140]:
- term [ref=f4e141]: 종류
- definition [ref=f4e142]: CASE
- button "저장" [disabled] [ref=f4e143]
- navigation "게시까지의 단계" [ref=f4e144]:
- list [ref=f4e145]:
- listitem [ref=f4e146]:
- generic [ref=f4e147]: "1"
- link "검증" [ref=f4e148] [cursor=pointer]:
- /url: /studio/documents/969cc2c7-be6e-42ca-924a-652c63db9784/validation
- listitem [ref=f4e149]:
- text:
- generic [ref=f4e150]: "2"
- link "미리보기" [ref=f4e151] [cursor=pointer]:
- /url: /studio/documents/969cc2c7-be6e-42ca-924a-652c63db9784/preview
- listitem [ref=f4e152]:
- text:
- generic [ref=f4e153]: "3"
- link "게시" [ref=f4e154] [cursor=pointer]:
- /url: /studio/documents/969cc2c7-be6e-42ca-924a-652c63db9784/publish
- paragraph [ref=f4e155]: 불완전한 초안도 저장할 수 있습니다. 게시 가능 여부는 이후 검증 단계에서 확인합니다.
- paragraph [ref=f4e50]: Case 작업본을 만들었습니다.
@@ -1,99 +0,0 @@
- generic [ref=f5e22]:
- link "본문으로 건너뛰기" [ref=f5e23] [cursor=pointer]:
- /url: "#main-content"
- banner [ref=f5e24]:
- generic [ref=f5e25]:
- link "TechLog Studio" [ref=f5e26] [cursor=pointer]:
- /url: /studio
- text: TechLog
- generic [ref=f5e27]: Studio
- navigation "Studio 주 탐색" [ref=f5e29]:
- link "작업본" [ref=f5e30] [cursor=pointer]:
- /url: /studio/documents
- link "게시 기록" [ref=f5e31] [cursor=pointer]:
- /url: /studio/publications
- link "새 문서" [ref=f5e32] [cursor=pointer]:
- /url: /studio/documents/new
- link "주제·프로젝트" [ref=f5e33] [cursor=pointer]:
- /url: /studio/taxonomy
- link "릴리즈" [ref=f5e34] [cursor=pointer]:
- /url: /studio/releases
- link "공개 사이트 보기" [ref=f5e35] [cursor=pointer]:
- /url: /
- button "로그아웃" [ref=f5e36]
- main [ref=f5e37]:
- generic [ref=f5e38]:
- generic [ref=f5e39]:
- generic [ref=f5e40]:
- paragraph [ref=f5e41]: WORKING COPIES
- heading "작업본" [level=1] [ref=f5e42]
- paragraph [ref=f5e43]: 세션에 있는 Case, Reference, Question, Decision을 찾고 다음 작업으로 이동합니다.
- link "새 문서" [ref=f5e44] [cursor=pointer]:
- /url: /studio/documents/new
- region "작업본 검색과 필터" [ref=f5e45]:
- search [ref=f5e46]:
- generic [ref=f5e47]: 검색
- generic [ref=f5e48]:
- searchbox "검색" [ref=f5e49]
- button "검색" [ref=f5e50]
- generic [ref=f5e51]:
- text: 종류
- combobox "종류" [ref=f5e52]:
- option "전체" [selected]
- option "Case"
- option "Reference"
- option "Question"
- option "Decision"
- generic [ref=f5e53]:
- text: 상태
- combobox "상태" [ref=f5e54]:
- option "전체" [selected]
- option "게시 전"
- option "게시 중"
- option "게시 취소"
- paragraph [ref=f5e55]:
- generic [ref=f5e56]: 2개 표시 중
- generic [ref=f5e57]:
- article [ref=f5e58]:
- paragraph [ref=f5e59]: Case
- generic [ref=f5e60]:
- heading [level=2] [ref=f5e61]:
- link "게시 흐름 확인" [ref=f5e62] [cursor=pointer]:
- /url: /studio/documents/b09f168b-b205-478f-b60f-83872d1b4d01/edit
- paragraph [ref=f5e63]: 프로젝트 미지정
- generic [ref=f5e64]:
- generic [ref=f5e65]:
- term [ref=f5e66]: 상태
- definition [ref=f5e67]: 게시 취소
- generic [ref=f5e68]:
- term [ref=f5e69]: 다음
- definition [ref=f5e70]:
- link "검증하기" [ref=f5e71] [cursor=pointer]:
- /url: /studio/documents/b09f168b-b205-478f-b60f-83872d1b4d01/validation
- generic [ref=f5e72]:
- term [ref=f5e73]: 수정
- definition [ref=f5e74]:
- time [ref=f5e75]: 2026. 8. 21.
- button "삭제" [ref=f5e76]
- article [ref=f5e77]:
- paragraph [ref=f5e78]: Case
- generic [ref=f5e79]:
- heading [level=2] [ref=f5e80]:
- link "브라우저에서 토큰을 어디까지 다뤄야 하는가..?" [ref=f5e81] [cursor=pointer]:
- /url: /studio/documents/f363edc8-2c13-4995-acda-934237034a85/edit
- paragraph [ref=f5e82]: 프로젝트 미지정
- generic [ref=f5e83]:
- generic [ref=f5e84]:
- term [ref=f5e85]: 상태
- definition [ref=f5e86]: 게시 전
- generic [ref=f5e87]:
- term [ref=f5e88]: 다음
- definition [ref=f5e89]:
- link "검증하기" [ref=f5e90] [cursor=pointer]:
- /url: /studio/documents/f363edc8-2c13-4995-acda-934237034a85/validation
- generic [ref=f5e91]:
- term [ref=f5e92]: 수정
- definition [ref=f5e93]:
- time [ref=f5e94]: 2026. 8. 21.
- button "삭제" [ref=f5e95]
- paragraph [ref=f5e96]: 작업본 제목 없음 을(를) 삭제했습니다.
@@ -1,55 +0,0 @@
- generic [ref=f6e3]:
- link "본문으로 건너뛰기" [ref=f6e4] [cursor=pointer]:
- /url: "#main-content"
- banner [ref=f6e5]:
- generic [ref=f6e6]:
- link "TechLog Studio" [ref=f6e7] [cursor=pointer]:
- /url: /studio
- text: TechLog
- generic [ref=f6e8]: Studio
- navigation "Studio 주 탐색" [ref=f6e10]:
- link "작업본" [ref=f6e11] [cursor=pointer]:
- /url: /studio/documents
- link "게시 기록" [ref=f6e12] [cursor=pointer]:
- /url: /studio/publications
- link "새 문서" [ref=f6e13] [cursor=pointer]:
- /url: /studio/documents/new
- link "주제·프로젝트" [ref=f6e14] [cursor=pointer]:
- /url: /studio/taxonomy
- link "릴리즈" [ref=f6e15] [cursor=pointer]:
- /url: /studio/releases
- link "공개 사이트 보기" [ref=f6e16] [cursor=pointer]:
- /url: /
- button "로그아웃" [ref=f6e17]
- main [ref=f6e18]:
- generic [ref=f6e19]:
- generic [ref=f6e20]:
- paragraph [ref=f6e21]: NEW WORKING COPY
- heading "새 문서" [level=1] [ref=f6e22]
- paragraph [ref=f6e23]: 목적에 맞는 기록 종류를 선택하면 빈 작업본을 만들고 바로 편집을 시작합니다.
- generic [ref=f6e24]:
- group "문서 종류" [ref=f6e25]:
- generic [ref=f6e27] [cursor=pointer]:
- radio "Case 문제를 재현하고 검증한 결론을 기록합니다. 문제 · 결론 · 환경 · 재현 · 본문" [ref=f6e28]
- strong [ref=f6e29]: Case
- generic [ref=f6e30]: 문제를 재현하고 검증한 결론을 기록합니다.
- generic [ref=f6e31]: 문제 · 결론 · 환경 · 재현 · 본문
- generic [ref=f6e32] [cursor=pointer]:
- radio "Reference 반복해서 적용할 기술 기준을 정리합니다. 목적 · 규칙 · 적용 조건 · 예외 · 예시" [ref=f6e33]
- strong [ref=f6e34]: Reference
- generic [ref=f6e35]: 반복해서 적용할 기술 기준을 정리합니다.
- generic [ref=f6e36]: 목적 · 규칙 · 적용 조건 · 예외 · 예시
- generic [ref=f6e37] [cursor=pointer]:
- radio "Question 아직 닫히지 않은 판단과 다음 검증을 관리합니다. 상태 · 사실 · 가정 · 미지수 · 선택지" [checked] [active] [ref=f6e38]
- strong [ref=f6e39]: Question
- generic [ref=f6e40]: 아직 닫히지 않은 판단과 다음 검증을 관리합니다.
- generic [ref=f6e41]: 상태 · 사실 · 가정 · 미지수 · 선택지
- generic [ref=f6e42] [cursor=pointer]:
- radio "Decision 프로젝트가 선택한 방향과 그 근거·영향을 기록합니다. 상태 · 결정일 · 결정문 · 판단 이유 · 영향 · 근거" [ref=f6e43]
- strong [ref=f6e44]: Decision
- generic [ref=f6e45]: 프로젝트가 선택한 방향과 그 근거·영향을 기록합니다.
- generic [ref=f6e46]: 상태 · 결정일 · 결정문 · 판단 이유 · 영향 · 근거
- generic [ref=f6e47]:
- button "작업본 만들기" [ref=f6e48]
- paragraph [ref=f6e49]: 이 화면의 작업본은 현재 Studio 세션에서만 유지됩니다.
- paragraph [ref=f6e50]
@@ -1,122 +0,0 @@
- generic [ref=f6e3]:
- link "본문으로 건너뛰기" [ref=f6e4] [cursor=pointer]:
- /url: "#main-content"
- banner [ref=f6e5]:
- generic [ref=f6e6]:
- link "TechLog Studio" [ref=f6e7] [cursor=pointer]:
- /url: /studio
- text: TechLog
- generic [ref=f6e8]: Studio
- navigation "Studio 주 탐색" [ref=f6e10]:
- link "작업본" [ref=f6e11] [cursor=pointer]:
- /url: /studio/documents
- link "게시 기록" [ref=f6e12] [cursor=pointer]:
- /url: /studio/publications
- link "새 문서" [ref=f6e13] [cursor=pointer]:
- /url: /studio/documents/new
- link "주제·프로젝트" [ref=f6e14] [cursor=pointer]:
- /url: /studio/taxonomy
- link "릴리즈" [ref=f6e15] [cursor=pointer]:
- /url: /studio/releases
- link "공개 사이트 보기" [ref=f6e16] [cursor=pointer]:
- /url: /
- button "로그아웃" [ref=f6e17]
- main [ref=f6e18]:
- generic [ref=f6e51]:
- tablist "문서 편집 화면" [ref=f6e52]:
- tab "편집" [selected] [ref=f6e53]
- tab "즉시 미리보기" [ref=f6e54]
- generic [ref=f6e55]:
- tabpanel "편집" [ref=f6e57]:
- generic [ref=f6e58]:
- paragraph [ref=f6e59]: QUESTION · VERSION 1
- heading "문서 편집" [level=1] [ref=f6e60]
- paragraph [ref=f6e61]: 제목 없는 작업본
- region [ref=f6e62]:
- generic [ref=f6e63]:
- paragraph [ref=f6e64]: DOCUMENT
- heading "기본 정보" [level=2] [ref=f6e65]
- generic [ref=f6e66]:
- generic [ref=f6e67]:
- generic [ref=f6e68]: 제목
- textbox "제목" [ref=f6e69]
- generic [ref=f6e70]:
- generic [ref=f6e71]: slug
- textbox "slug" [ref=f6e72]:
- /placeholder: 비우면 제목에서 만듭니다 (영문 소문자·숫자·하이픈)
- generic [ref=f6e73]:
- generic [ref=f6e74]: 요약
- textbox "요약" [ref=f6e75]
- generic [ref=f6e76]:
- generic [ref=f6e77]: Topic
- combobox "Topic" [ref=f6e78]:
- option "선택하지 않음" [selected]
- option "OAuth/OIDC 인증 경계"
- generic [ref=f6e79]:
- generic [ref=f6e80]: Project
- combobox "Project" [ref=f6e81]:
- option "미지정" [selected]
- option "Backend Clean Architecture"
- option "KeyCloak Patterns"
- option "Liner N + 1문제"
- group "관계" [ref=f6e82]:
- paragraph [ref=f6e84]: 연결한 공개 기록이 없습니다.
- button "관계 추가" [ref=f6e85]
- region [ref=f6e86]:
- generic [ref=f6e87]:
- paragraph [ref=f6e88]: QUESTION
- heading "판단과 다음 검증" [level=2] [ref=f6e89]
- generic [ref=f6e90]:
- generic [ref=f6e91]: 질문 상태
- combobox "질문 상태" [ref=f6e92]:
- option "아직 정하지 않음"
- option "OPEN" [selected]
- option "RESOLVED"
- group "사실" [ref=f6e93]:
- paragraph [ref=f6e95]: 아직 입력한 항목이 없습니다.
- button "사실 추가" [ref=f6e96]
- group "가정" [ref=f6e97]:
- paragraph [ref=f6e99]: 아직 입력한 항목이 없습니다.
- button "가정 추가" [ref=f6e100]
- group "미지수" [ref=f6e101]:
- paragraph [ref=f6e103]: 아직 입력한 항목이 없습니다.
- button "미지수 추가" [ref=f6e104]
- group "제약" [ref=f6e105]:
- paragraph [ref=f6e107]: 아직 입력한 항목이 없습니다.
- button "제약 추가" [ref=f6e108]
- group "선택지" [ref=f6e109]:
- paragraph [ref=f6e111]: 아직 입력한 선택지가 없습니다.
- button "선택지 추가" [ref=f6e112]
- generic [ref=f6e113]:
- generic [ref=f6e114]: 다음 검증
- textbox "다음 검증" [ref=f6e115]
- complementary [ref=f6e116]:
- paragraph [ref=f6e117]: WORKING COPY
- heading "작업 상태" [level=2] [ref=f6e118]
- status "편집 상태" [ref=f6e119]: 저장됨
- generic [ref=f6e120]:
- generic [ref=f6e121]:
- term [ref=f6e122]: 저장 버전
- definition [ref=f6e123]: "1"
- generic [ref=f6e124]:
- term [ref=f6e125]: 종류
- definition [ref=f6e126]: QUESTION
- button "저장" [disabled] [ref=f6e127]
- navigation "게시까지의 단계" [ref=f6e128]:
- list [ref=f6e129]:
- listitem [ref=f6e130]:
- generic [ref=f6e131]: "1"
- link "검증" [ref=f6e132] [cursor=pointer]:
- /url: /studio/documents/f54170bb-e1a2-466d-9d1c-39563e03266c/validation
- listitem [ref=f6e133]:
- text:
- generic [ref=f6e134]: "2"
- link "미리보기" [ref=f6e135] [cursor=pointer]:
- /url: /studio/documents/f54170bb-e1a2-466d-9d1c-39563e03266c/preview
- listitem [ref=f6e136]:
- text:
- generic [ref=f6e137]: "3"
- link "게시" [ref=f6e138] [cursor=pointer]:
- /url: /studio/documents/f54170bb-e1a2-466d-9d1c-39563e03266c/publish
- paragraph [ref=f6e139]: 불완전한 초안도 저장할 수 있습니다. 게시 가능 여부는 이후 검증 단계에서 확인합니다.
- paragraph [ref=f6e50]: Question 작업본을 만들었습니다.
@@ -1,99 +0,0 @@
- generic [ref=f7e3]:
- link "본문으로 건너뛰기" [ref=f7e4] [cursor=pointer]:
- /url: "#main-content"
- banner [ref=f7e5]:
- generic [ref=f7e6]:
- link "TechLog Studio" [ref=f7e7] [cursor=pointer]:
- /url: /studio
- text: TechLog
- generic [ref=f7e8]: Studio
- navigation "Studio 주 탐색" [ref=f7e10]:
- link "작업본" [ref=f7e11] [cursor=pointer]:
- /url: /studio/documents
- link "게시 기록" [ref=f7e12] [cursor=pointer]:
- /url: /studio/publications
- link "새 문서" [ref=f7e13] [cursor=pointer]:
- /url: /studio/documents/new
- link "주제·프로젝트" [ref=f7e14] [cursor=pointer]:
- /url: /studio/taxonomy
- link "릴리즈" [ref=f7e15] [cursor=pointer]:
- /url: /studio/releases
- link "공개 사이트 보기" [ref=f7e16] [cursor=pointer]:
- /url: /
- button "로그아웃" [ref=f7e17]
- main [ref=f7e18]:
- generic [ref=f7e19]:
- generic [ref=f7e20]:
- generic [ref=f7e21]:
- paragraph [ref=f7e22]: WORKING COPIES
- heading "작업본" [level=1] [ref=f7e23]
- paragraph [ref=f7e24]: 세션에 있는 Case, Reference, Question, Decision을 찾고 다음 작업으로 이동합니다.
- link "새 문서" [ref=f7e25] [cursor=pointer]:
- /url: /studio/documents/new
- region "작업본 검색과 필터" [ref=f7e26]:
- search [ref=f7e27]:
- generic [ref=f7e28]: 검색
- generic [ref=f7e29]:
- searchbox "검색" [ref=f7e30]
- button "검색" [ref=f7e31]
- generic [ref=f7e32]:
- text: 종류
- combobox "종류" [ref=f7e33]:
- option "전체" [selected]
- option "Case"
- option "Reference"
- option "Question"
- option "Decision"
- generic [ref=f7e34]:
- text: 상태
- combobox "상태" [ref=f7e35]:
- option "전체" [selected]
- option "게시 전"
- option "게시 중"
- option "게시 취소"
- paragraph [ref=f7e36]:
- generic [ref=f7e37]: 2개 표시 중
- generic [ref=f7e38]:
- article [ref=f7e39]:
- paragraph [ref=f7e40]: Case
- generic [ref=f7e41]:
- heading [level=2] [ref=f7e42]:
- link "게시 흐름 확인" [ref=f7e43] [cursor=pointer]:
- /url: /studio/documents/b09f168b-b205-478f-b60f-83872d1b4d01/edit
- paragraph [ref=f7e44]: 프로젝트 미지정
- generic [ref=f7e45]:
- generic [ref=f7e46]:
- term [ref=f7e47]: 상태
- definition [ref=f7e48]: 게시 취소
- generic [ref=f7e49]:
- term [ref=f7e50]: 다음
- definition [ref=f7e51]:
- link "검증하기" [ref=f7e52] [cursor=pointer]:
- /url: /studio/documents/b09f168b-b205-478f-b60f-83872d1b4d01/validation
- generic [ref=f7e53]:
- term [ref=f7e54]: 수정
- definition [ref=f7e55]:
- time [ref=f7e56]: 2026. 8. 21.
- button "삭제" [ref=f7e57]
- article [ref=f7e58]:
- paragraph [ref=f7e59]: Case
- generic [ref=f7e60]:
- heading [level=2] [ref=f7e61]:
- link "브라우저에서 토큰을 어디까지 다뤄야 하는가..?" [ref=f7e62] [cursor=pointer]:
- /url: /studio/documents/f363edc8-2c13-4995-acda-934237034a85/edit
- paragraph [ref=f7e63]: 프로젝트 미지정
- generic [ref=f7e64]:
- generic [ref=f7e65]:
- term [ref=f7e66]: 상태
- definition [ref=f7e67]: 게시 전
- generic [ref=f7e68]:
- term [ref=f7e69]: 다음
- definition [ref=f7e70]:
- link "검증하기" [ref=f7e71] [cursor=pointer]:
- /url: /studio/documents/f363edc8-2c13-4995-acda-934237034a85/validation
- generic [ref=f7e72]:
- term [ref=f7e73]: 수정
- definition [ref=f7e74]:
- time [ref=f7e75]: 2026. 8. 21.
- button "삭제" [ref=f7e76]
- paragraph [ref=f7e77]: 작업본 제목 없음 을(를) 삭제했습니다.
@@ -1,106 +0,0 @@
- generic [ref=f8e74]:
- link "본문으로 건너뛰기" [ref=f8e75] [cursor=pointer]:
- /url: "#main-content"
- banner [ref=f8e76]:
- generic [ref=f8e77]:
- link "TechLog Studio" [ref=f8e78] [cursor=pointer]:
- /url: /studio
- text: TechLog
- generic [ref=f8e79]: Studio
- navigation "Studio 주 탐색" [ref=f8e81]:
- link "작업본" [ref=f8e82] [cursor=pointer]:
- /url: /studio/documents
- link "게시 기록" [ref=f8e83] [cursor=pointer]:
- /url: /studio/publications
- link "새 문서" [ref=f8e84] [cursor=pointer]:
- /url: /studio/documents/new
- link "주제·프로젝트" [ref=f8e85] [cursor=pointer]:
- /url: /studio/taxonomy
- link "릴리즈" [ref=f8e86] [cursor=pointer]:
- /url: /studio/releases
- link "공개 사이트 보기" [ref=f8e87] [cursor=pointer]:
- /url: /
- button "로그아웃" [ref=f8e88]
- main [ref=f8e89]:
- generic [ref=f8e1]:
- generic [ref=f8e3]:
- paragraph [ref=f8e4]: TAXONOMY
- heading "주제와 프로젝트" [level=1] [ref=f8e5]
- paragraph [ref=f8e6]: 문서를 게시하려면 주제가 필요합니다. 여기서 만들고 정리합니다.
- region "주제 만들기" [ref=f8e7]:
- generic [ref=f8e8]:
- generic [ref=f8e9]: 새 주제
- generic [ref=f8e10]:
- textbox "새 주제" [ref=f8e11]:
- /placeholder: 주제 이름
- textbox "주제 slug" [ref=f8e12]:
- /placeholder: slug (비우면 이름에서 생성)
- button "추가" [ref=f8e13]
- generic [ref=f8e14]:
- generic [ref=f8e15]: 새 프로젝트
- generic [ref=f8e16]:
- textbox "새 프로젝트" [ref=f8e17]:
- /placeholder: 프로젝트 이름
- button "추가" [ref=f8e18]
- paragraph [ref=f8e90]: 2개의 주제
- generic [ref=f8e91]:
- article [ref=f8e92]:
- paragraph [ref=f8e93]: TOPIC
- generic [ref=f8e94]:
- heading "삭제 시험 주제" [level=2] [ref=f8e95]
- paragraph [ref=f8e96]: sakje-siheom-juje
- generic [ref=f8e98]:
- term [ref=f8e99]: 상태
- definition [ref=f8e100]: 사용 중
- button "삭제" [ref=f8e101]
- article [ref=f8e102]:
- paragraph [ref=f8e103]: TOPIC
- generic [ref=f8e104]:
- heading "OAuth/OIDC 인증 경계" [level=2] [ref=f8e105]
- paragraph [ref=f8e106]: oauth-oidc-auth-boundary
- generic [ref=f8e108]:
- term [ref=f8e109]: 상태
- definition [ref=f8e110]: 사용 중
- button "삭제" [ref=f8e111]
- paragraph [ref=f8e112]: 3개의 프로젝트
- generic [ref=f8e113]:
- article [ref=f8e114]:
- paragraph [ref=f8e115]: PROJECT
- generic [ref=f8e116]:
- heading "Liner N + 1문제" [level=2] [ref=f8e117]
- paragraph [ref=f8e118]: 목표 미지정
- generic [ref=f8e119]:
- generic [ref=f8e120]:
- term [ref=f8e121]: 단계
- definition [ref=f8e122]: RESEARCH
- generic [ref=f8e123]:
- term [ref=f8e124]: 공개
- definition [ref=f8e125]: PRIVATE
- button "삭제" [ref=f8e126]
- article [ref=f8e127]:
- paragraph [ref=f8e128]: PROJECT
- generic [ref=f8e129]:
- heading "KeyCloak Patterns" [level=2] [ref=f8e130]
- paragraph [ref=f8e131]: 목표 미지정
- generic [ref=f8e132]:
- generic [ref=f8e133]:
- term [ref=f8e134]: 단계
- definition [ref=f8e135]: RESEARCH
- generic [ref=f8e136]:
- term [ref=f8e137]: 공개
- definition [ref=f8e138]: PRIVATE
- button "삭제" [ref=f8e139]
- article [ref=f8e140]:
- paragraph [ref=f8e141]: PROJECT
- generic [ref=f8e142]:
- heading "Backend Clean Architecture" [level=2] [ref=f8e143]
- paragraph [ref=f8e144]: 목표 미지정
- generic [ref=f8e145]:
- generic [ref=f8e146]:
- term [ref=f8e147]: 단계
- definition [ref=f8e148]: RESEARCH
- generic [ref=f8e149]:
- term [ref=f8e150]: 공개
- definition [ref=f8e151]: PRIVATE
- button "삭제" [ref=f8e152]
- paragraph [ref=f8e153]: 주제 삭제 시험 주제 을(를) 만들었습니다.
@@ -1,96 +0,0 @@
- generic [ref=f8e74]:
- link "본문으로 건너뛰기" [ref=f8e75] [cursor=pointer]:
- /url: "#main-content"
- banner [ref=f8e76]:
- generic [ref=f8e77]:
- link "TechLog Studio" [ref=f8e78] [cursor=pointer]:
- /url: /studio
- text: TechLog
- generic [ref=f8e79]: Studio
- navigation "Studio 주 탐색" [ref=f8e81]:
- link "작업본" [ref=f8e82] [cursor=pointer]:
- /url: /studio/documents
- link "게시 기록" [ref=f8e83] [cursor=pointer]:
- /url: /studio/publications
- link "새 문서" [ref=f8e84] [cursor=pointer]:
- /url: /studio/documents/new
- link "주제·프로젝트" [ref=f8e85] [cursor=pointer]:
- /url: /studio/taxonomy
- link "릴리즈" [ref=f8e86] [cursor=pointer]:
- /url: /studio/releases
- link "공개 사이트 보기" [ref=f8e87] [cursor=pointer]:
- /url: /
- button "로그아웃" [ref=f8e88]
- main [ref=f8e89]:
- generic [ref=f8e1]:
- generic [ref=f8e3]:
- paragraph [ref=f8e4]: TAXONOMY
- heading "주제와 프로젝트" [level=1] [ref=f8e5]
- paragraph [ref=f8e6]: 문서를 게시하려면 주제가 필요합니다. 여기서 만들고 정리합니다.
- region "주제 만들기" [ref=f8e7]:
- generic [ref=f8e8]:
- generic [ref=f8e9]: 새 주제
- generic [ref=f8e10]:
- textbox "새 주제" [ref=f8e11]:
- /placeholder: 주제 이름
- textbox "주제 slug" [ref=f8e12]:
- /placeholder: slug (비우면 이름에서 생성)
- button "추가" [ref=f8e13]
- generic [ref=f8e14]:
- generic [ref=f8e15]: 새 프로젝트
- generic [ref=f8e16]:
- textbox "새 프로젝트" [ref=f8e17]:
- /placeholder: 프로젝트 이름
- button "추가" [ref=f8e18]
- paragraph [ref=f8e154]: 1개의 주제
- article [ref=f8e156]:
- paragraph [ref=f8e157]: TOPIC
- generic [ref=f8e158]:
- heading "OAuth/OIDC 인증 경계" [level=2] [ref=f8e159]
- paragraph [ref=f8e160]: oauth-oidc-auth-boundary
- generic [ref=f8e162]:
- term [ref=f8e163]: 상태
- definition [ref=f8e164]: 사용 중
- button "삭제" [ref=f8e165]
- paragraph [ref=f8e166]: 3개의 프로젝트
- generic [ref=f8e167]:
- article [ref=f8e168]:
- paragraph [ref=f8e169]: PROJECT
- generic [ref=f8e170]:
- heading "Liner N + 1문제" [level=2] [ref=f8e171]
- paragraph [ref=f8e172]: 목표 미지정
- generic [ref=f8e173]:
- generic [ref=f8e174]:
- term [ref=f8e175]: 단계
- definition [ref=f8e176]: RESEARCH
- generic [ref=f8e177]:
- term [ref=f8e178]: 공개
- definition [ref=f8e179]: PRIVATE
- button "삭제" [ref=f8e180]
- article [ref=f8e181]:
- paragraph [ref=f8e182]: PROJECT
- generic [ref=f8e183]:
- heading "KeyCloak Patterns" [level=2] [ref=f8e184]
- paragraph [ref=f8e185]: 목표 미지정
- generic [ref=f8e186]:
- generic [ref=f8e187]:
- term [ref=f8e188]: 단계
- definition [ref=f8e189]: RESEARCH
- generic [ref=f8e190]:
- term [ref=f8e191]: 공개
- definition [ref=f8e192]: PRIVATE
- button "삭제" [ref=f8e193]
- article [ref=f8e194]:
- paragraph [ref=f8e195]: PROJECT
- generic [ref=f8e196]:
- heading "Backend Clean Architecture" [level=2] [ref=f8e197]
- paragraph [ref=f8e198]: 목표 미지정
- generic [ref=f8e199]:
- generic [ref=f8e200]:
- term [ref=f8e201]: 단계
- definition [ref=f8e202]: RESEARCH
- generic [ref=f8e203]:
- term [ref=f8e204]: 공개
- definition [ref=f8e205]: PRIVATE
- button "삭제" [ref=f8e206]
- paragraph [ref=f8e153]: 주제 삭제 시험 주제 을(를) 삭제했습니다.
@@ -1,100 +0,0 @@
- generic [ref=f9e3]:
- link "본문으로 건너뛰기" [ref=f9e4] [cursor=pointer]:
- /url: "#main-content"
- banner [ref=f9e5]:
- generic [ref=f9e6]:
- link "TechLog Studio" [ref=f9e7] [cursor=pointer]:
- /url: /studio
- text: TechLog
- generic [ref=f9e8]: Studio
- navigation "Studio 주 탐색" [ref=f9e10]:
- link "작업본" [ref=f9e11] [cursor=pointer]:
- /url: /studio/documents
- link "게시 기록" [ref=f9e12] [cursor=pointer]:
- /url: /studio/publications
- link "새 문서" [ref=f9e13] [cursor=pointer]:
- /url: /studio/documents/new
- link "주제·프로젝트" [ref=f9e14] [cursor=pointer]:
- /url: /studio/taxonomy
- link "릴리즈" [ref=f9e15] [cursor=pointer]:
- /url: /studio/releases
- link "공개 사이트 보기" [ref=f9e16] [cursor=pointer]:
- /url: /
- button "로그아웃" [ref=f9e17]
- main [ref=f9e18]:
- generic [ref=f9e19]:
- generic [ref=f9e20]:
- generic [ref=f9e21]:
- paragraph [ref=f9e22]: WORKING COPIES
- heading "작업본" [level=1] [ref=f9e23]
- paragraph [ref=f9e24]: 세션에 있는 Case, Reference, Question, Decision을 찾고 다음 작업으로 이동합니다.
- link "새 문서" [ref=f9e25] [cursor=pointer]:
- /url: /studio/documents/new
- region "작업본 검색과 필터" [ref=f9e26]:
- search [ref=f9e27]:
- generic [ref=f9e28]: 검색
- generic [ref=f9e29]:
- searchbox "검색" [ref=f9e30]
- button "검색" [ref=f9e31]
- generic [ref=f9e32]:
- text: 종류
- combobox "종류" [ref=f9e33]:
- option "전체" [selected]
- option "Case"
- option "Reference"
- option "Question"
- option "Decision"
- generic [ref=f9e34]:
- text: 상태
- combobox "상태" [ref=f9e35]:
- option "전체" [selected]
- option "게시 전"
- option "게시 중"
- option "게시 취소"
- paragraph [ref=f9e36]:
- generic [ref=f9e37]: 2개 표시 중
- alert [ref=f9e38]: 삭제하지 못했습니다. 게시 중이거나, 이 기록을 참조하는 곳이 있거나, 다른 곳에서 먼저 수정되었을 수 있습니다.
- generic [ref=f9e39]:
- article [ref=f9e40]:
- paragraph [ref=f9e41]: Case
- generic [ref=f9e42]:
- heading [level=2] [ref=f9e43]:
- link "게시 흐름 확인" [ref=f9e44] [cursor=pointer]:
- /url: /studio/documents/b09f168b-b205-478f-b60f-83872d1b4d01/edit
- paragraph [ref=f9e45]: 프로젝트 미지정
- generic [ref=f9e46]:
- generic [ref=f9e47]:
- term [ref=f9e48]: 상태
- definition [ref=f9e49]: 게시 취소
- generic [ref=f9e50]:
- term [ref=f9e51]: 다음
- definition [ref=f9e52]:
- link "검증하기" [ref=f9e53] [cursor=pointer]:
- /url: /studio/documents/b09f168b-b205-478f-b60f-83872d1b4d01/validation
- generic [ref=f9e54]:
- term [ref=f9e55]: 수정
- definition [ref=f9e56]:
- time [ref=f9e57]: 2026. 8. 21.
- button "삭제" [ref=f9e58]
- article [ref=f9e59]:
- paragraph [ref=f9e60]: Case
- generic [ref=f9e61]:
- heading [level=2] [ref=f9e62]:
- link "브라우저에서 토큰을 어디까지 다뤄야 하는가..?" [ref=f9e63] [cursor=pointer]:
- /url: /studio/documents/f363edc8-2c13-4995-acda-934237034a85/edit
- paragraph [ref=f9e64]: 프로젝트 미지정
- generic [ref=f9e65]:
- generic [ref=f9e66]:
- term [ref=f9e67]: 상태
- definition [ref=f9e68]: 게시 전
- generic [ref=f9e69]:
- term [ref=f9e70]: 다음
- definition [ref=f9e71]:
- link "검증하기" [ref=f9e72] [cursor=pointer]:
- /url: /studio/documents/f363edc8-2c13-4995-acda-934237034a85/validation
- generic [ref=f9e73]:
- term [ref=f9e74]: 수정
- definition [ref=f9e75]:
- time [ref=f9e76]: 2026. 8. 21.
- button "삭제" [ref=f9e77]
- paragraph [ref=f9e78]
@@ -1,50 +0,0 @@
- generic [ref=f10e19]:
- link "본문으로 건너뛰기" [ref=f10e20] [cursor=pointer]:
- /url: "#main-content"
- banner [ref=f10e21]:
- generic [ref=f10e22]:
- link "TechLog Studio" [ref=f10e23] [cursor=pointer]:
- /url: /studio
- text: TechLog
- generic [ref=f10e24]: Studio
- navigation "Studio 주 탐색" [ref=f10e26]:
- link "작업본" [ref=f10e27] [cursor=pointer]:
- /url: /studio/documents
- link "게시 기록" [ref=f10e28] [cursor=pointer]:
- /url: /studio/publications
- link "새 문서" [ref=f10e29] [cursor=pointer]:
- /url: /studio/documents/new
- link "주제·프로젝트" [ref=f10e30] [cursor=pointer]:
- /url: /studio/taxonomy
- link "릴리즈" [ref=f10e31] [cursor=pointer]:
- /url: /studio/releases
- link "공개 사이트 보기" [ref=f10e32] [cursor=pointer]:
- /url: /
- button "로그아웃" [ref=f10e33]
- main [ref=f10e34]:
- generic [ref=f10e1]:
- generic [ref=f10e2]:
- paragraph [ref=f10e3]: ASSET LIBRARY
- heading "Asset" [level=1] [ref=f10e4]
- paragraph [ref=f10e5]: 업로드한 Asset을 검색하고 사용처를 확인하며, 사용하지 않는 Asset을 정리합니다.
- region "Asset 검색 도구" [ref=f10e6]:
- search [ref=f10e7]:
- generic [ref=f10e8]: Asset 검색
- generic [ref=f10e9]:
- searchbox "Asset 검색" [ref=f10e10]
- button "검색" [ref=f10e11]
- status
- status [ref=f10e12]: 1개의 Asset
- list [ref=f10e13]:
- listitem [ref=f10e14]:
- button "screenshot-from-2026-08-21-18-04-49-72f1f9c6" [ref=f10e15]
- text: READY
- generic [ref=f10e16]: 사용 0건
- generic "screenshot-from-2026-08-21-18-04-49-72f1f9c6 상세" [ref=f10e35]:
- heading "screenshot-from-2026-08-21-18-04-49-72f1f9c6" [active] [level=2] [ref=f10e36]
- paragraph [ref=f10e37]: READY
- paragraph [ref=f10e38]: 사용 중인 문서가 없습니다.
- generic [ref=f10e39]:
- button "삭제" [ref=f10e40]
- button "닫기" [ref=f10e41]
- paragraph [ref=f10e42]
@@ -0,0 +1,18 @@
# TECH_LOG_STUDIO_PROJECT_EDIT accessibility review
Status: pending-manual-review
Route ID: TECH_LOG_STUDIO_PROJECT_EDIT
Release ID:
Reviewer:
Reviewed at:
Signature:
Attestation: pending
M1 Keyboard: pending
M2 Visible focus: pending
M3 Route focus: pending
M4 Modal focus: pending
M5 Error association: pending
M6 Color signal: pending
M7 Reduced motion: pending
Screen reader: pending
Notes: Human review pending; automated evidence does not replace signed keyboard, focus, and screen-reader review.
@@ -0,0 +1,18 @@
# TECH_LOG_STUDIO_RELEASE_EDIT accessibility review
Status: pending-manual-review
Route ID: TECH_LOG_STUDIO_RELEASE_EDIT
Release ID:
Reviewer:
Reviewed at:
Signature:
Attestation: pending
M1 Keyboard: pending
M2 Visible focus: pending
M3 Route focus: pending
M4 Modal focus: pending
M5 Error association: pending
M6 Color signal: pending
M7 Reduced motion: pending
Screen reader: pending
Notes: Human review pending; automated evidence does not replace signed keyboard, focus, and screen-reader review.
BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 122 KiB

+14
View File
@@ -1202,12 +1202,24 @@
"schemaId": "markdown", "schemaId": "markdown",
"production": "source-controlled" "production": "source-controlled"
}, },
{
"id": "artifact-artifacts-tests-a11y-manual-TECH-LOG-STUDIO-PROJECT-EDIT-md",
"path": "artifacts/tests/a11y-manual/TECH_LOG_STUDIO_PROJECT_EDIT.md",
"schemaId": "markdown",
"production": "source-controlled"
},
{ {
"id": "artifact-artifacts-tests-a11y-manual-TECH-LOG-STUDIO-RELEASES-md", "id": "artifact-artifacts-tests-a11y-manual-TECH-LOG-STUDIO-RELEASES-md",
"path": "artifacts/tests/a11y-manual/TECH_LOG_STUDIO_RELEASES.md", "path": "artifacts/tests/a11y-manual/TECH_LOG_STUDIO_RELEASES.md",
"schemaId": "markdown", "schemaId": "markdown",
"production": "source-controlled" "production": "source-controlled"
}, },
{
"id": "artifact-artifacts-tests-a11y-manual-TECH-LOG-STUDIO-RELEASE-EDIT-md",
"path": "artifacts/tests/a11y-manual/TECH_LOG_STUDIO_RELEASE_EDIT.md",
"schemaId": "markdown",
"production": "source-controlled"
},
{ {
"id": "artifact-artifacts-tests-a11y-manual-TECH-LOG-STUDIO-NOT-FOUND-md", "id": "artifact-artifacts-tests-a11y-manual-TECH-LOG-STUDIO-NOT-FOUND-md",
"path": "artifacts/tests/a11y-manual/TECH_LOG_STUDIO_NOT_FOUND.md", "path": "artifacts/tests/a11y-manual/TECH_LOG_STUDIO_NOT_FOUND.md",
@@ -1962,7 +1974,9 @@
"artifact-artifacts-tests-a11y-manual-TECH-LOG-STUDIO-PUBLICATION-PREVIEW-md", "artifact-artifacts-tests-a11y-manual-TECH-LOG-STUDIO-PUBLICATION-PREVIEW-md",
"artifact-artifacts-tests-a11y-manual-TECH-LOG-STUDIO-ASSETS-md", "artifact-artifacts-tests-a11y-manual-TECH-LOG-STUDIO-ASSETS-md",
"artifact-artifacts-tests-a11y-manual-TECH-LOG-STUDIO-TAXONOMY-md", "artifact-artifacts-tests-a11y-manual-TECH-LOG-STUDIO-TAXONOMY-md",
"artifact-artifacts-tests-a11y-manual-TECH-LOG-STUDIO-PROJECT-EDIT-md",
"artifact-artifacts-tests-a11y-manual-TECH-LOG-STUDIO-RELEASES-md", "artifact-artifacts-tests-a11y-manual-TECH-LOG-STUDIO-RELEASES-md",
"artifact-artifacts-tests-a11y-manual-TECH-LOG-STUDIO-RELEASE-EDIT-md",
"artifact-artifacts-tests-a11y-manual-TECH-LOG-STUDIO-NOT-FOUND-md", "artifact-artifacts-tests-a11y-manual-TECH-LOG-STUDIO-NOT-FOUND-md",
"artifact-artifacts-tests-a11y-manual-NOT-FOUND-md", "artifact-artifacts-tests-a11y-manual-NOT-FOUND-md",
"artifact-artifacts-tests-a11y-manual-report-json" "artifact-artifacts-tests-a11y-manual-report-json"
+2 -2
View File
@@ -5,8 +5,8 @@
"registrationAllowed": false, "registrationAllowed": false,
"loginTheme": "keycloak", "loginTheme": "keycloak",
"accessTokenLifespan": 300, "accessTokenLifespan": 300,
"ssoSessionIdleTimeout": 1800, "ssoSessionIdleTimeout": 28800,
"ssoSessionMaxLifespan": 36000, "ssoSessionMaxLifespan": 86400,
"roles": { "roles": {
"realm": [ "realm": [
{ "name": "studio-author", "description": "Tech Log Studio 편집 권한 (studio:read + studio:write)" } { "name": "studio-author", "description": "Tech Log Studio 편집 권한 (studio:read + studio:write)" }
+14
View File
@@ -594,6 +594,20 @@ const commonSecurityRules = {
"CallExpression[callee.object.name='document'][callee.property.name='createElement'][arguments.0.value='script']", "CallExpression[callee.object.name='document'][callee.property.name='createElement'][arguments.0.value='script']",
message: "Runtime script construction is prohibited by FE-OC-019.", message: "Runtime script construction is prohibited by FE-OC-019.",
}, },
{
/*
setState 업데이터는 핸들러가 끝난 뒤, 다음 렌더에 실행된다. 그때 React 는 이미
`event.currentTarget` 을 null 로 되돌려 놓았으므로 업데이터 안에서 그것을 읽으면
"Cannot read properties of null" 로 화면이 통째로 죽는다.
타입 검사도 lint 도 잡지 못했고, 첫 입력에서야 드러났다 — 값은 핸들러가 도는 동안
지역 변수로 꺼내 두고 업데이터에는 그 값을 넘긴다.
*/
selector:
"CallExpression[callee.name=/^set[A-Z]/] > ArrowFunctionExpression MemberExpression[property.name='currentTarget']",
message:
"Read event.currentTarget before the setState updater runs — it is null by the time the updater is called.",
},
], ],
}; };
BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 27 KiB

+8 -8
View File
@@ -38,28 +38,28 @@
}, },
"contractSet": { "contractSet": {
"setAlgorithm": "CA_CONTRACT_SET_V1", "setAlgorithm": "CA_CONTRACT_SET_V1",
"setDigest": "sha256:7ee35548d2a84b744f8c17c7b785a79b328dcf756f5c9df8d77d37ff1535b087", "setDigest": "sha256:cdcfb628a502d71596f1162726eb395aad0f5f92cf05fd77d304f8e51c81b2fc",
"packages": [ "packages": [
{ {
"packageId": "@tech-log/management-contract", "packageId": "@tech-log/management-contract",
"version": "1.0.0", "version": "1.0.0",
"digest": "sha256:d19ae7c4fbcac924a356bbb0cc1a46a4046ecec701158ca0a9b7cc089bbaf878", "digest": "sha256:72650735061fde627f5037571eb986cb758f44a546f065c88408399f8eec4a55",
"runtimeProtocolVersion": 1, "runtimeProtocolVersion": 1,
"sourceRevision": "b195b29" "sourceRevision": "ef49d3a"
}, },
{ {
"packageId": "@tech-log/public-contract", "packageId": "@tech-log/public-contract",
"version": "2.1.0", "version": "2.1.0",
"digest": "sha256:37e6f804165de3e492e975075bea563ee41ae74076222a3562d3452631bfdb2b", "digest": "sha256:7eb668e39e279e49767306dd36e1dd51302071c39d78495d21307bbd9676220e",
"runtimeProtocolVersion": 1, "runtimeProtocolVersion": 1,
"sourceRevision": "b195b29" "sourceRevision": "ef49d3a"
}, },
{ {
"packageId": "@tech-log/studio-contract", "packageId": "@tech-log/studio-contract",
"version": "3.0.0", "version": "3.1.0",
"digest": "sha256:674327a82951fd4a1bc2594c858072dfcd0b9abe7c63198283da5d8c92a04326", "digest": "sha256:18dd46898be64b07f7e826409d19347512613ee2e22420028a4a0644f50f37dd",
"runtimeProtocolVersion": 1, "runtimeProtocolVersion": 1,
"sourceRevision": "b195b29" "sourceRevision": "ef49d3a"
} }
] ]
} }
+13 -3
View File
@@ -468,7 +468,12 @@ const CANONICAL_GATE_SHAPE_SHA256 =
// TECH_LOG_STUDIO_RELEASES evidence artifact, by the same method — 8c73d447… // TECH_LOG_STUDIO_RELEASES evidence artifact, by the same method — 8c73d447…
// was first reproduced from the previous gates.json, so the computation that // was first reproduced from the previous gates.json, so the computation that
// produced this value is known to be the one the constant was pinned under. // produced this value is known to be the one the constant was pinned under.
"187dbd9676b7b3444409b5af4143d49927b1eacc67f57a099f4a3765bb64d865"; // 프로젝트 편집 화면: FE-GATE-009 가 TECH_LOG_STUDIO_PROJECT_EDIT 증거 아티팩트를
// 얻어 다시 계산했다. 같은 방법이다 — 187dbd96… 을 이전 gates.json 에서 먼저 재현해,
// 이 값을 만든 계산이 상수가 고정될 때 쓰인 그 계산임을 확인했다.
// 릴리즈 편집 화면: 같은 방법으로 다시 계산했다. f9e7e521… 을 이전 gates.json 에서 먼저
// 재현했다.
"fb138e7c51fdf969f755cd8ff32cf627f1750b966d212c33ee996c8c578db0e3";
function canonicalGateShapeSha256(gates: CiGateContract["gates"]): string { function canonicalGateShapeSha256(gates: CiGateContract["gates"]): string {
const normalized = gates.map( const normalized = gates.map(
@@ -523,8 +528,13 @@ function canonicalAuthorityBaselineFailures(contract: CiGateContract): string[]
// Alignment follow-up, item 2 added the TechLog junit report. // Alignment follow-up, item 2 added the TechLog junit report.
// The taxonomy route added its own manual a11y evidence file — every installed // The taxonomy route added its own manual a11y evidence file — every installed
// route carries one, and the gate checks that the two sets match exactly. // route carries one, and the gate checks that the two sets match exactly.
if (contract.artifacts.length !== 132) { // The project edit route did the same: it is what finally lets a project carry
failures.push(`artifact authority baseline must contain exactly 132 artifacts; received ${contract.artifacts.length}`); // a purpose, a current objective, and a next step, so the public screens that
// read those fields stop rendering blanks.
// The release edit route followed: the editor used to open below the release
// list, so editing meant scrolling past every release to reach it.
if (contract.artifacts.length !== 134) {
failures.push(`artifact authority baseline must contain exactly 134 artifacts; received ${contract.artifacts.length}`);
} }
if (contract.stages.length !== 5) { if (contract.stages.length !== 5) {
failures.push(`stage authority baseline must contain exactly 5 stages; received ${contract.stages.length}`); failures.push(`stage authority baseline must contain exactly 5 stages; received ${contract.stages.length}`);
@@ -1,5 +1,10 @@
import type { import type {
CreateDraftResponse, CreateDraftResponse,
HomeFocusRequest,
HomeFocusResponse,
ProjectActivityRequest,
ProjectActivityResponse,
UpdateProjectActivityRequest,
ProjectEditResponse, ProjectEditResponse,
ProjectIndexPage, ProjectIndexPage,
ProjectUpdateRequest, ProjectUpdateRequest,
@@ -85,6 +90,28 @@ export function createHttpManagementGateway(
deleteRelease: async (id: string, expectedVersion: number) => { deleteRelease: async (id: string, expectedVersion: number) => {
await run<void>("deleteRelease", { id, expectedVersion }); await run<void>("deleteRelease", { id, expectedVersion });
}, },
listProjectActivities: (id: string) =>
run<ProjectActivityResponse[]>("listStudioProjectActivities", { id }),
createProjectActivity: (id: string, body: ProjectActivityRequest) =>
run<ProjectActivityResponse>("createProjectActivity", { id, body }),
updateProjectActivity: (
id: string,
activityId: string,
body: UpdateProjectActivityRequest,
) => run<ProjectActivityResponse>("updateProjectActivity", { id, activityId, body }),
deleteProjectActivity: async (id: string, activityId: string, expectedVersion: number) => {
await run<void>("deleteProjectActivity", { id, activityId, expectedVersion });
},
publishProject: (
id: string,
expectedVersion: number,
visibility: "PUBLIC" | "UNLISTED" = "PUBLIC",
) => run<PublishResponse>("publishProject", { id, expectedVersion, visibility }),
unpublishProject: (id: string, expectedVersion: number) =>
run<ProjectEditResponse>("unpublishProject", { id, expectedVersion }),
getHomeFocus: () => run<HomeFocusResponse>("getHomeFocus", {}),
updateHomeFocus: (body: HomeFocusRequest) =>
run<HomeFocusResponse>("updateHomeFocus", body),
publishRelease: (id: string, expectedVersion: number) => publishRelease: (id: string, expectedVersion: number) =>
run<PublishResponse>("publishRelease", { id, expectedVersion }), run<PublishResponse>("publishRelease", { id, expectedVersion }),
archiveRelease: (id: string, expectedVersion: number) => archiveRelease: (id: string, expectedVersion: number) =>
@@ -1,5 +1,6 @@
import type { import type {
HomeFocusItem, HomeFocusItem,
LatestRecordEntry,
ProjectActivity, ProjectActivity,
ProjectDecision, ProjectDecision,
Project, Project,
@@ -19,12 +20,12 @@ import {
decisionItemToDecision, decisionItemToDecision,
flattenRelations, flattenRelations,
knowledgeListItemToRecord, knowledgeListItemToRecord,
markdownLines,
markdownSections, markdownSections,
questionListItemToRecord, questionListItemToRecord,
releaseDetailToRelease, releaseDetailToRelease,
searchItemToEntity, searchItemToEntity,
} from "./public-content-mapping.ts"; } from "./public-content-mapping.ts";
import type { components } from "../../contracts/public/generated.ts";
import type { StudioOperationExecutor } from "./http-studio-gateway.ts"; import type { StudioOperationExecutor } from "./http-studio-gateway.ts";
const ROUTE_ID = "TECH_LOG_PUBLIC"; const ROUTE_ID = "TECH_LOG_PUBLIC";
@@ -141,13 +142,19 @@ export function createHttpPublicContentGateway(
const canonicalPath = String(detail.canonicalPath ?? ""); const canonicalPath = String(detail.canonicalPath ?? "");
const groups = (detail.relations as Readonly<Record<string, never>>) ?? {}; const groups = (detail.relations as Readonly<Record<string, never>>) ?? {};
const projectOf = (entry: Readonly<{ title?: string; path?: string }> | undefined) =>
entry?.path
? { name: entry.title ?? "", slug: entry.path.split("/").filter(Boolean).pop() ?? "", path: entry.path }
: undefined;
if (kind === "CASE") { if (kind === "CASE") {
const body = (detail.case as Readonly<Record<string, unknown>>) ?? {}; const body = (detail.case as Readonly<Record<string, unknown>>) ?? {};
return Object.freeze({ return Object.freeze({
...baseOf("CASE", slug, { ...baseOf("CASE", slug, {
title: body.title as string, title: body.title as string,
summary: body.problemSummary as string, // 제목 바로 아래에 오는 것은 문서의 요약이다. 유형별 요약(문제/범위)을 쓰면 바로 아래
// 블록과 같은 글을 두 번 말한다.
summary: (body.summary as string) ?? "",
path: canonicalPath, path: canonicalPath,
primaryTopic: body.primaryTopic as never, primaryTopic: body.primaryTopic as never,
primaryProject: body.primaryProject as never, primaryProject: body.primaryProject as never,
@@ -192,7 +199,7 @@ export function createHttpPublicContentGateway(
return Object.freeze({ return Object.freeze({
...baseOf("REFERENCE", slug, { ...baseOf("REFERENCE", slug, {
title: body.title as string, title: body.title as string,
summary: body.purposeSummary as string, summary: (body.summary as string) ?? "",
path: canonicalPath, path: canonicalPath,
primaryTopic: body.primaryTopic as never, primaryTopic: body.primaryTopic as never,
primaryProject: body.primaryProject as never, primaryProject: body.primaryProject as never,
@@ -204,48 +211,90 @@ export function createHttpPublicContentGateway(
}), }),
}), }),
kind: "REFERENCE", kind: "REFERENCE",
purpose: (body.purposeSummary as string) ?? "", /*
여기서 읽는 이름은 계약이 실제로 주는 이름이어야 한다. 한때 `purposeSummary`,
`applyWhenMarkdown`, `exceptionsMarkdown`, `examplesMarkdown` 을 읽었는데 계약에는 그런
칸이 없다 — 전부 undefined 로 떨어져 공개 Reference 화면이 통째로 비었다. Studio 에서는
같은 글이 다 보이므로 "공개 쪽만 안 나온다" 로 드러났다.
규칙과 예시는 `content` 마크다운을 잘라 만드는 것이 아니라 계약이 구조로 준다. Studio 의
편집기가 제목과 본문을 따로 받기 때문이다.
*/
purpose: (body.scopeSummary as string) ?? "",
rules: Object.freeze( rules: Object.freeze(
markdownSections(body.content as string).map((section) => ({ ((body.rules as readonly Readonly<Record<string, unknown>>[] | undefined) ?? []).map(
title: section.title, (rule) => ({
body: section.paragraphs.join("\n"), title: String(rule.title ?? ""),
})), body: String(rule.body ?? ""),
}),
), ),
applyWhen: Object.freeze(markdownLines(body.applyWhenMarkdown as string)), ),
exceptions: Object.freeze(markdownLines(body.exceptionsMarkdown as string)), applyWhen: Object.freeze(((body.appliesTo as readonly string[] | undefined) ?? []).map(String)),
examples: Object.freeze(markdownLines(body.examplesMarkdown as string)), exceptions: Object.freeze(
((body.excludedScope as readonly string[] | undefined) ?? []).map(String),
),
examples: Object.freeze(((body.examples as readonly string[] | undefined) ?? []).map(String)),
verifiedAt: dateLabel(body.lastVerifiedAt as string), verifiedAt: dateLabel(body.lastVerifiedAt as string),
}) as unknown as Extract<PublicRecord, { kind: K }>; }) as unknown as Extract<PublicRecord, { kind: K }>;
} }
const body = (detail.question as Readonly<Record<string, unknown>>) ?? {}; const body = (detail.question as Readonly<Record<string, unknown>>) ?? {};
const points = (body.points as readonly Readonly<Record<string, unknown>>[] | undefined) ?? []; /*
const pointsOf = (group: string) => `points` 는 그룹 이름을 키로 갖는 객체다 — 계약의 `QuestionPointGroup`. 여기서는
Object.freeze( `{group, items}` 배열로 읽으면서 `.filter` 를 불렀고, 객체에는 그런 것이 없으니 상세
points 화면이 통째로 「요청을 처리하지 못했습니다」가 됐다. 목록은 이 칸을 비워 두고 만들기
.filter((point) => point.group === group) 때문에 탐색에서는 멀쩡히 보였고, 그래서 "게시했는데 안 뜬다" 로만 드러났다.
.flatMap((point) => (point.items as readonly string[] | undefined) ?? []),
); `as` 캐스트가 그 어긋남을 타입 검사에서 가렸다. 계약의 타입을 그대로 쓰면 다음에 모양이
바뀔 때 컴파일이 먼저 막는다.
*/
type QuestionPoints = components["schemas"]["QuestionPointGroup"];
const points = body.points as QuestionPoints | undefined;
const pointsOf = (group: keyof QuestionPoints) =>
Object.freeze([...(points?.[group] ?? [])].map(String));
return Object.freeze({ return Object.freeze({
...baseOf("QUESTION", slug, { ...baseOf("QUESTION", slug, {
title: body.question as string, title: body.question as string,
summary: body.summary as string, summary: body.summary as string,
path: canonicalPath, path: canonicalPath,
primaryTopic: body.primaryTopic as never, primaryTopic: body.primaryTopic as never,
primaryProject: body.primaryProject as never, /*
질문 상세는 프로젝트를 `question` 이 아니라 `relations.primaryProject` 에 담는다 —
Case/Reference 와 다른 자리다. `question` 에서 찾고 있었으므로 머리말의 프로젝트
칸이 늘 비어 있었다.
그 자리의 값은 `RelatedEntry` 라 `title`/`path` 를 쓴다. 머리말이 기다리는 것은
`name`/`slug` 이므로 여기서 옮겨 준다 — slug 는 경로의 마지막 마디다.
*/
primaryProject: projectOf(groups.primaryProject) as never,
publishedAt: body.updatedAt as string, publishedAt: body.updatedAt as string,
relations: flattenRelations(groups, { /*
derivedCases: "이 질문에서 나온 기록", 계약이 주는 이름은 `resultCase` / `producedDecision` / `derivedReferences` 다.
projectDecisions: "이 질문이 이끈 결정", 여기서는 `derivedCases` / `projectDecisions` / `relatedQuestions` 를 찾고 있었고,
relatedQuestions: "관련 질문", 하나도 맞지 않아 이유 자리에 영문 키가 그대로 나왔다.
}),
`primaryProject` 는 관계가 아니라 이 질문이 속한 프로젝트다 — 머리말이 이미
보여 주므로 관계 목록에 넣지 않는다.
*/
relations: flattenRelations(
{
resultCase: groups.resultCase,
producedDecision: groups.producedDecision,
derivedReferences: groups.derivedReferences,
},
{
resultCase: "이 질문에서 나온 기록",
producedDecision: "이 질문이 이끈 결정",
derivedReferences: "이 질문에서 정리된 기준",
},
),
}), }),
kind: "QUESTION", kind: "QUESTION",
questionStatus: (body.status as QuestionRecord["questionStatus"]) ?? "OPEN", questionStatus: (body.status as QuestionRecord["questionStatus"]) ?? "OPEN",
facts: pointsOf("KNOWN_FACT"), facts: pointsOf("facts"),
assumptions: pointsOf("ASSUMPTION"), assumptions: pointsOf("assumptions"),
unknowns: pointsOf("UNRESOLVED"), unknowns: pointsOf("unknowns"),
constraints: pointsOf("CONSTRAINT"), constraints: pointsOf("constraints"),
options: Object.freeze([]), options: Object.freeze([]),
nextValidation: (body.nextVerification as string) ?? "", nextValidation: (body.nextVerification as string) ?? "",
}) as unknown as Extract<PublicRecord, { kind: K }>; }) as unknown as Extract<PublicRecord, { kind: K }>;
@@ -320,6 +369,33 @@ export function createHttpPublicContentGateway(
); );
} }
/**
* 공개 투영이 고른 최근 기록. `entryType` 은 계약이 네 값만 허용하므로 그대로 믿고 쓴다 —
* 서버가 이미 걸러 보낸다.
*/
async function getLatestEntries(): Promise<LatestRecordEntry[]> {
const home = await read<Readonly<{ latestEntries?: readonly Readonly<Record<string, unknown>>[] }>>(
"getPublicHome",
{},
);
if (home === NOT_FOUND) return [];
return (home.latestEntries ?? []).map((entry) => {
const topic = entry.primaryTopic as Readonly<Record<string, unknown>> | null | undefined;
const project = entry.primaryProject as Readonly<Record<string, unknown>> | null | undefined;
const path = String(entry.path ?? "");
return Object.freeze({
id: `${String(entry.entryType ?? "")}:${path}`,
entryType: String(entry.entryType ?? "CASE") as LatestRecordEntry["entryType"],
title: String(entry.title ?? ""),
summary: String(entry.summary ?? ""),
path,
publishedAt: String(entry.publishedAt ?? ""),
topic: topic ? String(topic.name ?? "") : "",
project: project ? String(project.name ?? "") : "",
});
});
}
async function getHomeFocusItems(): Promise<HomeFocusItem[]> { async function getHomeFocusItems(): Promise<HomeFocusItem[]> {
const home = await read<Readonly<{ focus?: Readonly<Record<string, never>> }>>( const home = await read<Readonly<{ focus?: Readonly<Record<string, never>> }>>(
"getPublicHome", "getPublicHome",
@@ -463,6 +539,7 @@ export function createHttpPublicContentGateway(
getProjectDecisions, getProjectDecisions,
getProjectActivity, getProjectActivity,
getHomeFocusItems, getHomeFocusItems,
getLatestEntries,
searchPublicContent, searchPublicContent,
}); });
} }
@@ -11,6 +11,7 @@ import {
type HomeFocusItem, type HomeFocusItem,
} from "./public-content.ts"; } from "./public-content.ts";
import type { import type {
LatestRecordEntry,
PublicContentQueries, PublicContentQueries,
PublicTopic, PublicTopic,
} from "../../application/ports/public-content-queries.ts"; } from "../../application/ports/public-content-queries.ts";
@@ -53,6 +54,7 @@ export function listRecords(filters: RecordFilters = {}): PublicRecord[] {
.filter( .filter(
(record) => (record) =>
!hasTopicFilter || !hasTopicFilter ||
record.topicSlug.toLocaleLowerCase("ko-KR") === requestedTopic ||
record.topic.toLocaleLowerCase("ko-KR") === requestedTopic, record.topic.toLocaleLowerCase("ko-KR") === requestedTopic,
) )
.filter( .filter(
@@ -119,6 +121,38 @@ export function listTopics(): PublicTopic[] {
.map((entry) => Object.freeze(entry)); .map((entry) => Object.freeze(entry));
} }
/**
* 픽스처판 "최근 기록". HTTP 어댑터가 서버에서 읽어 오는 것과 같은 의미를 픽스처에서 만든다 —
* 프로젝트 활동과 릴리스가 원천이다.
*/
export function getLatestEntries(): LatestRecordEntry[] {
const recordByPath = new Map(publicRecords.map((record) => [record.path, record]));
const activities = projects.flatMap((project) =>
project.activity.map((activity) => {
const record = recordByPath.get(activity.recordPath ?? activity.path);
const published = activity.type === "PUBLICATION" && record;
return {
id: activity.id,
entryType: (published ? record.kind : "PROJECT_ACTIVITY") as LatestRecordEntry["entryType"],
title: published ? record.title : activity.title,
summary: activity.summary,
path: activity.path,
publishedAt: activity.dateTime,
topic: record?.topic ?? project.topics[0] ?? "",
project: project.title,
};
}),
);
/*
릴리스는 넣지 않는다. 이 목록은 서버의 `latestEntries` 와 같은 의미여야 하고, 그쪽은 공개
투영에서 고르므로 릴리스가 없다 — 릴리스는 Publication 파이프라인을 거치지 않는다. 홈 화면이
릴리스를 따로 읽어 합치므로, 여기서도 넣으면 같은 릴리스가 두 번 나온다.
*/
return [...activities].sort((left, right) =>
right.publishedAt.localeCompare(left.publishedAt),
);
}
export function getHomeFocusItems(): HomeFocusItem[] { export function getHomeFocusItems(): HomeFocusItem[] {
const project = getProject("backend-skeleton"); const project = getProject("backend-skeleton");
const question = getRecord("QUESTION", "validate-edge-token-again"); const question = getRecord("QUESTION", "validate-edge-token-again");
@@ -264,6 +298,9 @@ export const publicContentQueries = Object.freeze({
async listTopics() { async listTopics() {
return listTopics(); return listTopics();
}, },
async getLatestEntries() {
return getLatestEntries();
},
async getHomeFocusItems() { async getHomeFocusItems() {
return getHomeFocusItems(); return getHomeFocusItems();
}, },
@@ -1,5 +1,10 @@
import type { import type {
CreateDraftResponse, CreateDraftResponse,
HomeFocusRequest,
HomeFocusResponse,
ProjectActivityRequest,
ProjectActivityResponse,
UpdateProjectActivityRequest,
ProjectEditResponse, ProjectEditResponse,
ProjectIndexPage, ProjectIndexPage,
ProjectUpdateRequest, ProjectUpdateRequest,
@@ -27,6 +32,33 @@ export type ManagementGateway = Readonly<{
createProject(title: string): Promise<CreateDraftResponse>; createProject(title: string): Promise<CreateDraftResponse>;
updateProject(id: string, body: ProjectUpdateRequest): Promise<ProjectEditResponse>; updateProject(id: string, body: ProjectUpdateRequest): Promise<ProjectEditResponse>;
deleteProject(id: string, expectedVersion: number): Promise<void>; deleteProject(id: string, expectedVersion: number): Promise<void>;
/**
* 프로젝트 게시. 프로젝트는 Studio 문서가 아니라 게시 파이프라인 밖에 있고, 그래서 문서를
* 게시해도 그 문서가 속한 프로젝트는 비공개로 남는다 — 공개 화면(프로젝트 목록·프로필의
* "현재 프로젝트"·홈의 focus)은 모두 게시된 프로젝트만 읽으므로, 이 호출 없이는 어디에도
* 나타나지 않는다.
*/
publishProject(
id: string,
expectedVersion: number,
visibility?: "PUBLIC" | "UNLISTED",
): Promise<PublishResponse>;
unpublishProject(id: string, expectedVersion: number): Promise<ProjectEditResponse>;
/**
* 프로젝트 활동. 공개 프로젝트 화면의 "활동" 은 이 목록을 투영 없이 직접 읽으므로, 여기서
* 만든 줄이 곧 그 화면이다.
*/
listProjectActivities(id: string): Promise<ProjectActivityResponse[]>;
createProjectActivity(id: string, body: ProjectActivityRequest): Promise<ProjectActivityResponse>;
updateProjectActivity(
id: string,
activityId: string,
body: UpdateProjectActivityRequest,
): Promise<ProjectActivityResponse>;
deleteProjectActivity(id: string, activityId: string, expectedVersion: number): Promise<void>;
/** 공개 홈이 무엇을 앞에 둘지. 세 슬롯이 모두 비면 홈은 그 영역을 아예 그리지 않는다. */
getHomeFocus(): Promise<HomeFocusResponse>;
updateHomeFocus(body: HomeFocusRequest): Promise<HomeFocusResponse>;
listReleases(page?: number, size?: number): Promise<ReleaseIndexPage>; listReleases(page?: number, size?: number): Promise<ReleaseIndexPage>;
getRelease(id: string): Promise<ReleaseEditResponse>; getRelease(id: string): Promise<ReleaseEditResponse>;
createRelease(title: string): Promise<CreateDraftResponse>; createRelease(title: string): Promise<CreateDraftResponse>;
@@ -164,6 +164,24 @@ export type PublicTopic = {
recordCount: number; recordCount: number;
}; };
/**
* 홈의 "최근 기록" 한 줄. 서버가 공개 투영에서 직접 고른다.
*
* 화면이 프로젝트를 하나씩 돌며 조립하던 때에는, 게시된 문서라도 그 문서가 매달린 프로젝트가
* 공개되어 있지 않으면 목록에서 통째로 빠졌다 — 실제로 게시한 Case 는 안 보이고 릴리스만
* 남았다. 무엇이 최근인지는 공개 투영 하나가 알고 있으므로 거기서 그대로 읽는다.
*/
export type LatestRecordEntry = {
id: string;
entryType: "CASE" | "REFERENCE" | "QUESTION" | "PROJECT_ACTIVITY" | "RELEASE";
title: string;
summary: string;
path: string;
publishedAt: string;
topic: string;
project: string;
};
export type FocusKey = "current" | "question" | "decision"; export type FocusKey = "current" | "question" | "decision";
export type HomeFocusItem = { export type HomeFocusItem = {
@@ -227,6 +245,7 @@ export type PublicContentQueries = Readonly<{
* 있었고, Studio 에서 주제를 만들어도 바뀌지 않았다. * 있었고, Studio 에서 주제를 만들어도 바뀌지 않았다.
*/ */
listTopics(): Promise<PublicTopic[]>; listTopics(): Promise<PublicTopic[]>;
getLatestEntries(): Promise<LatestRecordEntry[]>;
getHomeFocusItems(): Promise<HomeFocusItem[]>; getHomeFocusItems(): Promise<HomeFocusItem[]>;
searchPublicContent(query: string): Promise<SearchablePublicEntity[]>; searchPublicContent(query: string): Promise<SearchablePublicEntity[]>;
}>; }>;
@@ -1,8 +1,8 @@
{ {
"packageId": "@tech-log/management-contract", "packageId": "@tech-log/management-contract",
"version": "1.0.0", "version": "1.0.0",
"digest": "sha256:d19ae7c4fbcac924a356bbb0cc1a46a4046ecec701158ca0a9b7cc089bbaf878", "digest": "sha256:72650735061fde627f5037571eb986cb758f44a546f065c88408399f8eec4a55",
"sourceRevision": "b195b29", "sourceRevision": "ef49d3a",
"operationIds": [ "operationIds": [
"createCaseDraft", "createCaseDraft",
"getCaseForEdit", "getCaseForEdit",
@@ -83,6 +83,7 @@
"updateHomeFocus", "updateHomeFocus",
"listStudioProjectActivities", "listStudioProjectActivities",
"createProjectActivity", "createProjectActivity",
"updateProjectActivity" "updateProjectActivity",
"deleteProjectActivity"
] ]
} }
@@ -15,3 +15,8 @@ export type ReleaseIndexItem = Schemas["ReleaseIndexItem"];
export type ReleaseIndexPage = Schemas["ReleaseIndexPage"]; export type ReleaseIndexPage = Schemas["ReleaseIndexPage"];
export type ReleaseUpdateRequest = Schemas["ReleaseUpdateRequest"]; export type ReleaseUpdateRequest = Schemas["ReleaseUpdateRequest"];
export type PublishResponse = Schemas["PublishResponse"]; export type PublishResponse = Schemas["PublishResponse"];
export type HomeFocusRequest = Schemas["HomeFocusRequest"];
export type HomeFocusResponse = Schemas["HomeFocusResponse"];
export type ProjectActivityRequest = Schemas["ProjectActivityRequest"];
export type UpdateProjectActivityRequest = Schemas["UpdateProjectActivityRequest"];
export type ProjectActivityResponse = Schemas["ProjectActivityResponse"];
@@ -841,7 +841,7 @@ export interface paths {
patch?: never; patch?: never;
trace?: never; trace?: never;
}; };
"/api/v1/studio/home-focus": { "/api/v1/studio/home/focus": {
parameters: { parameters: {
query?: never; query?: never;
header?: never; header?: never;
@@ -883,7 +883,12 @@ export interface paths {
get?: never; get?: never;
put: operations["updateProjectActivity"]; put: operations["updateProjectActivity"];
post?: never; post?: never;
delete?: never; /** @description . ,
* .
*
* `AUTO`
* . `MANUAL` . */
delete: operations["deleteProjectActivity"];
options?: never; options?: never;
head?: never; head?: never;
patch?: never; patch?: never;
@@ -963,6 +968,24 @@ export interface components {
data: components["schemas"]["ReleaseEditResponse"]; data: components["schemas"]["ReleaseEditResponse"];
meta: components["schemas"]["ResponseMeta"]; meta: components["schemas"]["ResponseMeta"];
}; };
HomeFocusResponseEnvelope: {
/** @constant */
success: true;
data: components["schemas"]["HomeFocusResponse"];
meta: components["schemas"]["ResponseMeta"];
};
ProjectActivityResponseEnvelope: {
/** @constant */
success: true;
data: components["schemas"]["ProjectActivityResponse"];
meta: components["schemas"]["ResponseMeta"];
};
ProjectActivityListEnvelope: {
/** @constant */
success: true;
data: components["schemas"]["ProjectActivityResponse"][];
meta: components["schemas"]["ResponseMeta"];
};
PublishResponseEnvelope: { PublishResponseEnvelope: {
/** @constant */ /** @constant */
success: true; success: true;
@@ -5519,7 +5542,7 @@ export interface operations {
[name: string]: unknown; [name: string]: unknown;
}; };
content: { content: {
"application/json": components["schemas"]["PublishResponse"]; "application/json": components["schemas"]["PublishResponseEnvelope"];
}; };
}; };
/** @description Bad Request */ /** @description Bad Request */
@@ -5528,7 +5551,7 @@ export interface operations {
[name: string]: unknown; [name: string]: unknown;
}; };
content: { content: {
"application/problem+json": components["schemas"]["ProblemDetails"]; "application/json": components["schemas"]["ErrorEnvelope"];
}; };
}; };
/** @description Unauthorized */ /** @description Unauthorized */
@@ -5537,7 +5560,7 @@ export interface operations {
[name: string]: unknown; [name: string]: unknown;
}; };
content: { content: {
"application/problem+json": components["schemas"]["ProblemDetails"]; "application/json": components["schemas"]["ErrorEnvelope"];
}; };
}; };
/** @description Forbidden */ /** @description Forbidden */
@@ -5546,7 +5569,7 @@ export interface operations {
[name: string]: unknown; [name: string]: unknown;
}; };
content: { content: {
"application/problem+json": components["schemas"]["ProblemDetails"]; "application/json": components["schemas"]["ErrorEnvelope"];
}; };
}; };
/** @description Not Found */ /** @description Not Found */
@@ -5555,7 +5578,7 @@ export interface operations {
[name: string]: unknown; [name: string]: unknown;
}; };
content: { content: {
"application/problem+json": components["schemas"]["ProblemDetails"]; "application/json": components["schemas"]["ErrorEnvelope"];
}; };
}; };
/** @description Conflict */ /** @description Conflict */
@@ -5564,7 +5587,7 @@ export interface operations {
[name: string]: unknown; [name: string]: unknown;
}; };
content: { content: {
"application/problem+json": components["schemas"]["ProblemDetails"]; "application/json": components["schemas"]["ErrorEnvelope"];
}; };
}; };
/** @description Unprocessable Content */ /** @description Unprocessable Content */
@@ -5573,7 +5596,7 @@ export interface operations {
[name: string]: unknown; [name: string]: unknown;
}; };
content: { content: {
"application/problem+json": components["schemas"]["ProblemDetails"]; "application/json": components["schemas"]["ErrorEnvelope"];
}; };
}; };
/** @description Internal Server Error */ /** @description Internal Server Error */
@@ -5582,7 +5605,7 @@ export interface operations {
[name: string]: unknown; [name: string]: unknown;
}; };
content: { content: {
"application/problem+json": components["schemas"]["ProblemDetails"]; "application/json": components["schemas"]["ErrorEnvelope"];
}; };
}; };
}; };
@@ -5610,7 +5633,7 @@ export interface operations {
[name: string]: unknown; [name: string]: unknown;
}; };
content: { content: {
"application/json": components["schemas"]["ProjectEditResponse"]; "application/json": components["schemas"]["ProjectEditResponseEnvelope"];
}; };
}; };
/** @description Bad Request */ /** @description Bad Request */
@@ -5619,7 +5642,7 @@ export interface operations {
[name: string]: unknown; [name: string]: unknown;
}; };
content: { content: {
"application/problem+json": components["schemas"]["ProblemDetails"]; "application/json": components["schemas"]["ErrorEnvelope"];
}; };
}; };
/** @description Unauthorized */ /** @description Unauthorized */
@@ -5628,7 +5651,7 @@ export interface operations {
[name: string]: unknown; [name: string]: unknown;
}; };
content: { content: {
"application/problem+json": components["schemas"]["ProblemDetails"]; "application/json": components["schemas"]["ErrorEnvelope"];
}; };
}; };
/** @description Forbidden */ /** @description Forbidden */
@@ -5637,7 +5660,7 @@ export interface operations {
[name: string]: unknown; [name: string]: unknown;
}; };
content: { content: {
"application/problem+json": components["schemas"]["ProblemDetails"]; "application/json": components["schemas"]["ErrorEnvelope"];
}; };
}; };
/** @description Not Found */ /** @description Not Found */
@@ -5646,7 +5669,7 @@ export interface operations {
[name: string]: unknown; [name: string]: unknown;
}; };
content: { content: {
"application/problem+json": components["schemas"]["ProblemDetails"]; "application/json": components["schemas"]["ErrorEnvelope"];
}; };
}; };
/** @description Conflict */ /** @description Conflict */
@@ -5655,7 +5678,7 @@ export interface operations {
[name: string]: unknown; [name: string]: unknown;
}; };
content: { content: {
"application/problem+json": components["schemas"]["ProblemDetails"]; "application/json": components["schemas"]["ErrorEnvelope"];
}; };
}; };
/** @description Unprocessable Content */ /** @description Unprocessable Content */
@@ -5664,7 +5687,7 @@ export interface operations {
[name: string]: unknown; [name: string]: unknown;
}; };
content: { content: {
"application/problem+json": components["schemas"]["ProblemDetails"]; "application/json": components["schemas"]["ErrorEnvelope"];
}; };
}; };
/** @description Internal Server Error */ /** @description Internal Server Error */
@@ -5673,7 +5696,7 @@ export interface operations {
[name: string]: unknown; [name: string]: unknown;
}; };
content: { content: {
"application/problem+json": components["schemas"]["ProblemDetails"]; "application/json": components["schemas"]["ErrorEnvelope"];
}; };
}; };
}; };
@@ -8117,7 +8140,7 @@ export interface operations {
[name: string]: unknown; [name: string]: unknown;
}; };
content: { content: {
"application/json": components["schemas"]["HomeFocusResponse"]; "application/json": components["schemas"]["HomeFocusResponseEnvelope"];
}; };
}; };
/** @description Bad Request */ /** @description Bad Request */
@@ -8126,7 +8149,7 @@ export interface operations {
[name: string]: unknown; [name: string]: unknown;
}; };
content: { content: {
"application/problem+json": components["schemas"]["ProblemDetails"]; "application/json": components["schemas"]["ErrorEnvelope"];
}; };
}; };
/** @description Unauthorized */ /** @description Unauthorized */
@@ -8135,7 +8158,7 @@ export interface operations {
[name: string]: unknown; [name: string]: unknown;
}; };
content: { content: {
"application/problem+json": components["schemas"]["ProblemDetails"]; "application/json": components["schemas"]["ErrorEnvelope"];
}; };
}; };
/** @description Forbidden */ /** @description Forbidden */
@@ -8144,7 +8167,7 @@ export interface operations {
[name: string]: unknown; [name: string]: unknown;
}; };
content: { content: {
"application/problem+json": components["schemas"]["ProblemDetails"]; "application/json": components["schemas"]["ErrorEnvelope"];
}; };
}; };
/** @description Not Found */ /** @description Not Found */
@@ -8153,7 +8176,7 @@ export interface operations {
[name: string]: unknown; [name: string]: unknown;
}; };
content: { content: {
"application/problem+json": components["schemas"]["ProblemDetails"]; "application/json": components["schemas"]["ErrorEnvelope"];
}; };
}; };
/** @description Internal Server Error */ /** @description Internal Server Error */
@@ -8162,7 +8185,7 @@ export interface operations {
[name: string]: unknown; [name: string]: unknown;
}; };
content: { content: {
"application/problem+json": components["schemas"]["ProblemDetails"]; "application/json": components["schemas"]["ErrorEnvelope"];
}; };
}; };
}; };
@@ -8188,7 +8211,7 @@ export interface operations {
[name: string]: unknown; [name: string]: unknown;
}; };
content: { content: {
"application/json": components["schemas"]["HomeFocusResponse"]; "application/json": components["schemas"]["HomeFocusResponseEnvelope"];
}; };
}; };
/** @description Bad Request */ /** @description Bad Request */
@@ -8197,7 +8220,7 @@ export interface operations {
[name: string]: unknown; [name: string]: unknown;
}; };
content: { content: {
"application/problem+json": components["schemas"]["ProblemDetails"]; "application/json": components["schemas"]["ErrorEnvelope"];
}; };
}; };
/** @description Unauthorized */ /** @description Unauthorized */
@@ -8206,7 +8229,7 @@ export interface operations {
[name: string]: unknown; [name: string]: unknown;
}; };
content: { content: {
"application/problem+json": components["schemas"]["ProblemDetails"]; "application/json": components["schemas"]["ErrorEnvelope"];
}; };
}; };
/** @description Forbidden */ /** @description Forbidden */
@@ -8215,7 +8238,7 @@ export interface operations {
[name: string]: unknown; [name: string]: unknown;
}; };
content: { content: {
"application/problem+json": components["schemas"]["ProblemDetails"]; "application/json": components["schemas"]["ErrorEnvelope"];
}; };
}; };
/** @description Not Found */ /** @description Not Found */
@@ -8224,7 +8247,7 @@ export interface operations {
[name: string]: unknown; [name: string]: unknown;
}; };
content: { content: {
"application/problem+json": components["schemas"]["ProblemDetails"]; "application/json": components["schemas"]["ErrorEnvelope"];
}; };
}; };
/** @description Conflict */ /** @description Conflict */
@@ -8233,7 +8256,7 @@ export interface operations {
[name: string]: unknown; [name: string]: unknown;
}; };
content: { content: {
"application/problem+json": components["schemas"]["ProblemDetails"]; "application/json": components["schemas"]["ErrorEnvelope"];
}; };
}; };
/** @description Unprocessable Content */ /** @description Unprocessable Content */
@@ -8242,7 +8265,7 @@ export interface operations {
[name: string]: unknown; [name: string]: unknown;
}; };
content: { content: {
"application/problem+json": components["schemas"]["ProblemDetails"]; "application/json": components["schemas"]["ErrorEnvelope"];
}; };
}; };
/** @description Internal Server Error */ /** @description Internal Server Error */
@@ -8251,7 +8274,7 @@ export interface operations {
[name: string]: unknown; [name: string]: unknown;
}; };
content: { content: {
"application/problem+json": components["schemas"]["ProblemDetails"]; "application/json": components["schemas"]["ErrorEnvelope"];
}; };
}; };
}; };
@@ -8273,7 +8296,7 @@ export interface operations {
[name: string]: unknown; [name: string]: unknown;
}; };
content: { content: {
"application/json": components["schemas"]["ProjectActivityResponse"][]; "application/json": components["schemas"]["ProjectActivityListEnvelope"];
}; };
}; };
/** @description 401 */ /** @description 401 */
@@ -8282,7 +8305,7 @@ export interface operations {
[name: string]: unknown; [name: string]: unknown;
}; };
content: { content: {
"application/problem+json": components["schemas"]["ProblemDetails"]; "application/json": components["schemas"]["ErrorEnvelope"];
}; };
}; };
/** @description 403 */ /** @description 403 */
@@ -8291,7 +8314,7 @@ export interface operations {
[name: string]: unknown; [name: string]: unknown;
}; };
content: { content: {
"application/problem+json": components["schemas"]["ProblemDetails"]; "application/json": components["schemas"]["ErrorEnvelope"];
}; };
}; };
/** @description 404 */ /** @description 404 */
@@ -8300,7 +8323,7 @@ export interface operations {
[name: string]: unknown; [name: string]: unknown;
}; };
content: { content: {
"application/problem+json": components["schemas"]["ProblemDetails"]; "application/json": components["schemas"]["ErrorEnvelope"];
}; };
}; };
/** @description 500 */ /** @description 500 */
@@ -8309,7 +8332,7 @@ export interface operations {
[name: string]: unknown; [name: string]: unknown;
}; };
content: { content: {
"application/problem+json": components["schemas"]["ProblemDetails"]; "application/json": components["schemas"]["ErrorEnvelope"];
}; };
}; };
}; };
@@ -8337,7 +8360,7 @@ export interface operations {
[name: string]: unknown; [name: string]: unknown;
}; };
content: { content: {
"application/json": components["schemas"]["ProjectActivityResponse"]; "application/json": components["schemas"]["ProjectActivityResponseEnvelope"];
}; };
}; };
/** @description 400 */ /** @description 400 */
@@ -8346,7 +8369,7 @@ export interface operations {
[name: string]: unknown; [name: string]: unknown;
}; };
content: { content: {
"application/problem+json": components["schemas"]["ProblemDetails"]; "application/json": components["schemas"]["ErrorEnvelope"];
}; };
}; };
/** @description 401 */ /** @description 401 */
@@ -8355,7 +8378,7 @@ export interface operations {
[name: string]: unknown; [name: string]: unknown;
}; };
content: { content: {
"application/problem+json": components["schemas"]["ProblemDetails"]; "application/json": components["schemas"]["ErrorEnvelope"];
}; };
}; };
/** @description 403 */ /** @description 403 */
@@ -8364,7 +8387,7 @@ export interface operations {
[name: string]: unknown; [name: string]: unknown;
}; };
content: { content: {
"application/problem+json": components["schemas"]["ProblemDetails"]; "application/json": components["schemas"]["ErrorEnvelope"];
}; };
}; };
/** @description 404 */ /** @description 404 */
@@ -8373,7 +8396,7 @@ export interface operations {
[name: string]: unknown; [name: string]: unknown;
}; };
content: { content: {
"application/problem+json": components["schemas"]["ProblemDetails"]; "application/json": components["schemas"]["ErrorEnvelope"];
}; };
}; };
/** @description 409 */ /** @description 409 */
@@ -8382,7 +8405,7 @@ export interface operations {
[name: string]: unknown; [name: string]: unknown;
}; };
content: { content: {
"application/problem+json": components["schemas"]["ProblemDetails"]; "application/json": components["schemas"]["ErrorEnvelope"];
}; };
}; };
/** @description 422 */ /** @description 422 */
@@ -8391,7 +8414,7 @@ export interface operations {
[name: string]: unknown; [name: string]: unknown;
}; };
content: { content: {
"application/problem+json": components["schemas"]["ProblemDetails"]; "application/json": components["schemas"]["ErrorEnvelope"];
}; };
}; };
/** @description 500 */ /** @description 500 */
@@ -8400,7 +8423,7 @@ export interface operations {
[name: string]: unknown; [name: string]: unknown;
}; };
content: { content: {
"application/problem+json": components["schemas"]["ProblemDetails"]; "application/json": components["schemas"]["ErrorEnvelope"];
}; };
}; };
}; };
@@ -8429,7 +8452,7 @@ export interface operations {
[name: string]: unknown; [name: string]: unknown;
}; };
content: { content: {
"application/json": components["schemas"]["ProjectActivityResponse"]; "application/json": components["schemas"]["ProjectActivityResponseEnvelope"];
}; };
}; };
/** @description 400 */ /** @description 400 */
@@ -8438,7 +8461,7 @@ export interface operations {
[name: string]: unknown; [name: string]: unknown;
}; };
content: { content: {
"application/problem+json": components["schemas"]["ProblemDetails"]; "application/json": components["schemas"]["ErrorEnvelope"];
}; };
}; };
/** @description 401 */ /** @description 401 */
@@ -8447,7 +8470,7 @@ export interface operations {
[name: string]: unknown; [name: string]: unknown;
}; };
content: { content: {
"application/problem+json": components["schemas"]["ProblemDetails"]; "application/json": components["schemas"]["ErrorEnvelope"];
}; };
}; };
/** @description 403 */ /** @description 403 */
@@ -8456,7 +8479,7 @@ export interface operations {
[name: string]: unknown; [name: string]: unknown;
}; };
content: { content: {
"application/problem+json": components["schemas"]["ProblemDetails"]; "application/json": components["schemas"]["ErrorEnvelope"];
}; };
}; };
/** @description 404 */ /** @description 404 */
@@ -8465,7 +8488,7 @@ export interface operations {
[name: string]: unknown; [name: string]: unknown;
}; };
content: { content: {
"application/problem+json": components["schemas"]["ProblemDetails"]; "application/json": components["schemas"]["ErrorEnvelope"];
}; };
}; };
/** @description 409 */ /** @description 409 */
@@ -8474,7 +8497,7 @@ export interface operations {
[name: string]: unknown; [name: string]: unknown;
}; };
content: { content: {
"application/problem+json": components["schemas"]["ProblemDetails"]; "application/json": components["schemas"]["ErrorEnvelope"];
}; };
}; };
/** @description 422 */ /** @description 422 */
@@ -8483,7 +8506,7 @@ export interface operations {
[name: string]: unknown; [name: string]: unknown;
}; };
content: { content: {
"application/problem+json": components["schemas"]["ProblemDetails"]; "application/json": components["schemas"]["ErrorEnvelope"];
}; };
}; };
/** @description 500 */ /** @description 500 */
@@ -8492,7 +8515,97 @@ export interface operations {
[name: string]: unknown; [name: string]: unknown;
}; };
content: { content: {
"application/problem+json": components["schemas"]["ProblemDetails"]; "application/json": components["schemas"]["ErrorEnvelope"];
};
};
};
};
deleteProjectActivity: {
parameters: {
query?: never;
header: {
"X-CSRF-TOKEN": components["parameters"]["CsrfToken"];
};
path: {
id: string;
activityId: string;
};
cookie?: never;
};
requestBody: {
content: {
"application/json": components["schemas"]["ExpectedVersionRequest"];
};
};
responses: {
/** @description No Content */
204: {
headers: {
[name: string]: unknown;
};
content?: never;
};
/** @description Bad Request */
400: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["ErrorEnvelope"];
};
};
/** @description Unauthorized */
401: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["ErrorEnvelope"];
};
};
/** @description Forbidden */
403: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["ErrorEnvelope"];
};
};
/** @description Not Found */
404: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["ErrorEnvelope"];
};
};
/** @description Conflict */
409: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["ErrorEnvelope"];
};
};
/** @description Unprocessable Content */
422: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["ErrorEnvelope"];
};
};
/** @description Internal Server Error */
500: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["ErrorEnvelope"];
}; };
}; };
}; };
@@ -3015,49 +3015,49 @@ paths:
content: content:
application/json: application/json:
schema: schema:
$ref: '#/components/schemas/PublishResponse' $ref: '#/components/schemas/PublishResponseEnvelope'
'400': '400':
description: Bad Request description: Bad Request
content: content:
application/problem+json: application/json:
schema: schema:
$ref: '#/components/schemas/ProblemDetails' $ref: '#/components/schemas/ErrorEnvelope'
'401': '401':
description: Unauthorized description: Unauthorized
content: content:
application/problem+json: application/json:
schema: schema:
$ref: '#/components/schemas/ProblemDetails' $ref: '#/components/schemas/ErrorEnvelope'
'403': '403':
description: Forbidden description: Forbidden
content: content:
application/problem+json: application/json:
schema: schema:
$ref: '#/components/schemas/ProblemDetails' $ref: '#/components/schemas/ErrorEnvelope'
'404': '404':
description: Not Found description: Not Found
content: content:
application/problem+json: application/json:
schema: schema:
$ref: '#/components/schemas/ProblemDetails' $ref: '#/components/schemas/ErrorEnvelope'
'409': '409':
description: Conflict description: Conflict
content: content:
application/problem+json: application/json:
schema: schema:
$ref: '#/components/schemas/ProblemDetails' $ref: '#/components/schemas/ErrorEnvelope'
'422': '422':
description: Unprocessable Content description: Unprocessable Content
content: content:
application/problem+json: application/json:
schema: schema:
$ref: '#/components/schemas/ProblemDetails' $ref: '#/components/schemas/ErrorEnvelope'
'500': '500':
description: Internal Server Error description: Internal Server Error
content: content:
application/problem+json: application/json:
schema: schema:
$ref: '#/components/schemas/ProblemDetails' $ref: '#/components/schemas/ErrorEnvelope'
requestBody: requestBody:
required: true required: true
content: content:
@@ -3085,49 +3085,49 @@ paths:
content: content:
application/json: application/json:
schema: schema:
$ref: '#/components/schemas/ProjectEditResponse' $ref: '#/components/schemas/ProjectEditResponseEnvelope'
'400': '400':
description: Bad Request description: Bad Request
content: content:
application/problem+json: application/json:
schema: schema:
$ref: '#/components/schemas/ProblemDetails' $ref: '#/components/schemas/ErrorEnvelope'
'401': '401':
description: Unauthorized description: Unauthorized
content: content:
application/problem+json: application/json:
schema: schema:
$ref: '#/components/schemas/ProblemDetails' $ref: '#/components/schemas/ErrorEnvelope'
'403': '403':
description: Forbidden description: Forbidden
content: content:
application/problem+json: application/json:
schema: schema:
$ref: '#/components/schemas/ProblemDetails' $ref: '#/components/schemas/ErrorEnvelope'
'404': '404':
description: Not Found description: Not Found
content: content:
application/problem+json: application/json:
schema: schema:
$ref: '#/components/schemas/ProblemDetails' $ref: '#/components/schemas/ErrorEnvelope'
'409': '409':
description: Conflict description: Conflict
content: content:
application/problem+json: application/json:
schema: schema:
$ref: '#/components/schemas/ProblemDetails' $ref: '#/components/schemas/ErrorEnvelope'
'422': '422':
description: Unprocessable Content description: Unprocessable Content
content: content:
application/problem+json: application/json:
schema: schema:
$ref: '#/components/schemas/ProblemDetails' $ref: '#/components/schemas/ErrorEnvelope'
'500': '500':
description: Internal Server Error description: Internal Server Error
content: content:
application/problem+json: application/json:
schema: schema:
$ref: '#/components/schemas/ProblemDetails' $ref: '#/components/schemas/ErrorEnvelope'
requestBody: requestBody:
required: true required: true
content: content:
@@ -4973,7 +4973,7 @@ paths:
$ref: '#/components/schemas/ExpectedVersionRequest' $ref: '#/components/schemas/ExpectedVersionRequest'
security: security:
- sessionCookie: [] - sessionCookie: []
/api/v1/studio/home-focus: /api/v1/studio/home/focus:
get: get:
operationId: getHomeFocus operationId: getHomeFocus
tags: tags:
@@ -4985,37 +4985,37 @@ paths:
content: content:
application/json: application/json:
schema: schema:
$ref: '#/components/schemas/HomeFocusResponse' $ref: '#/components/schemas/HomeFocusResponseEnvelope'
'400': '400':
description: Bad Request description: Bad Request
content: content:
application/problem+json: application/json:
schema: schema:
$ref: '#/components/schemas/ProblemDetails' $ref: '#/components/schemas/ErrorEnvelope'
'401': '401':
description: Unauthorized description: Unauthorized
content: content:
application/problem+json: application/json:
schema: schema:
$ref: '#/components/schemas/ProblemDetails' $ref: '#/components/schemas/ErrorEnvelope'
'403': '403':
description: Forbidden description: Forbidden
content: content:
application/problem+json: application/json:
schema: schema:
$ref: '#/components/schemas/ProblemDetails' $ref: '#/components/schemas/ErrorEnvelope'
'404': '404':
description: Not Found description: Not Found
content: content:
application/problem+json: application/json:
schema: schema:
$ref: '#/components/schemas/ProblemDetails' $ref: '#/components/schemas/ErrorEnvelope'
'500': '500':
description: Internal Server Error description: Internal Server Error
content: content:
application/problem+json: application/json:
schema: schema:
$ref: '#/components/schemas/ProblemDetails' $ref: '#/components/schemas/ErrorEnvelope'
security: security:
- sessionCookie: [] - sessionCookie: []
put: put:
@@ -5030,49 +5030,49 @@ paths:
content: content:
application/json: application/json:
schema: schema:
$ref: '#/components/schemas/HomeFocusResponse' $ref: '#/components/schemas/HomeFocusResponseEnvelope'
'400': '400':
description: Bad Request description: Bad Request
content: content:
application/problem+json: application/json:
schema: schema:
$ref: '#/components/schemas/ProblemDetails' $ref: '#/components/schemas/ErrorEnvelope'
'401': '401':
description: Unauthorized description: Unauthorized
content: content:
application/problem+json: application/json:
schema: schema:
$ref: '#/components/schemas/ProblemDetails' $ref: '#/components/schemas/ErrorEnvelope'
'403': '403':
description: Forbidden description: Forbidden
content: content:
application/problem+json: application/json:
schema: schema:
$ref: '#/components/schemas/ProblemDetails' $ref: '#/components/schemas/ErrorEnvelope'
'404': '404':
description: Not Found description: Not Found
content: content:
application/problem+json: application/json:
schema: schema:
$ref: '#/components/schemas/ProblemDetails' $ref: '#/components/schemas/ErrorEnvelope'
'409': '409':
description: Conflict description: Conflict
content: content:
application/problem+json: application/json:
schema: schema:
$ref: '#/components/schemas/ProblemDetails' $ref: '#/components/schemas/ErrorEnvelope'
'422': '422':
description: Unprocessable Content description: Unprocessable Content
content: content:
application/problem+json: application/json:
schema: schema:
$ref: '#/components/schemas/ProblemDetails' $ref: '#/components/schemas/ErrorEnvelope'
'500': '500':
description: Internal Server Error description: Internal Server Error
content: content:
application/problem+json: application/json:
schema: schema:
$ref: '#/components/schemas/ProblemDetails' $ref: '#/components/schemas/ErrorEnvelope'
requestBody: requestBody:
required: true required: true
content: content:
@@ -5101,33 +5101,31 @@ paths:
content: content:
application/json: application/json:
schema: schema:
type: array $ref: '#/components/schemas/ProjectActivityListEnvelope'
items:
$ref: '#/components/schemas/ProjectActivityResponse'
'401': '401':
description: '401' description: '401'
content: content:
application/problem+json: application/json:
schema: schema:
$ref: '#/components/schemas/ProblemDetails' $ref: '#/components/schemas/ErrorEnvelope'
'403': '403':
description: '403' description: '403'
content: content:
application/problem+json: application/json:
schema: schema:
$ref: '#/components/schemas/ProblemDetails' $ref: '#/components/schemas/ErrorEnvelope'
'404': '404':
description: '404' description: '404'
content: content:
application/problem+json: application/json:
schema: schema:
$ref: '#/components/schemas/ProblemDetails' $ref: '#/components/schemas/ErrorEnvelope'
'500': '500':
description: '500' description: '500'
content: content:
application/problem+json: application/json:
schema: schema:
$ref: '#/components/schemas/ProblemDetails' $ref: '#/components/schemas/ErrorEnvelope'
post: post:
operationId: createProjectActivity operationId: createProjectActivity
tags: tags:
@@ -5154,49 +5152,49 @@ paths:
content: content:
application/json: application/json:
schema: schema:
$ref: '#/components/schemas/ProjectActivityResponse' $ref: '#/components/schemas/ProjectActivityResponseEnvelope'
'400': '400':
description: '400' description: '400'
content: content:
application/problem+json: application/json:
schema: schema:
$ref: '#/components/schemas/ProblemDetails' $ref: '#/components/schemas/ErrorEnvelope'
'401': '401':
description: '401' description: '401'
content: content:
application/problem+json: application/json:
schema: schema:
$ref: '#/components/schemas/ProblemDetails' $ref: '#/components/schemas/ErrorEnvelope'
'403': '403':
description: '403' description: '403'
content: content:
application/problem+json: application/json:
schema: schema:
$ref: '#/components/schemas/ProblemDetails' $ref: '#/components/schemas/ErrorEnvelope'
'404': '404':
description: '404' description: '404'
content: content:
application/problem+json: application/json:
schema: schema:
$ref: '#/components/schemas/ProblemDetails' $ref: '#/components/schemas/ErrorEnvelope'
'409': '409':
description: '409' description: '409'
content: content:
application/problem+json: application/json:
schema: schema:
$ref: '#/components/schemas/ProblemDetails' $ref: '#/components/schemas/ErrorEnvelope'
'422': '422':
description: '422' description: '422'
content: content:
application/problem+json: application/json:
schema: schema:
$ref: '#/components/schemas/ProblemDetails' $ref: '#/components/schemas/ErrorEnvelope'
'500': '500':
description: '500' description: '500'
content: content:
application/problem+json: application/json:
schema: schema:
$ref: '#/components/schemas/ProblemDetails' $ref: '#/components/schemas/ErrorEnvelope'
/api/v1/studio/projects/{id}/activities/{activityId}: /api/v1/studio/projects/{id}/activities/{activityId}:
put: put:
operationId: updateProjectActivity operationId: updateProjectActivity
@@ -5230,49 +5228,126 @@ paths:
content: content:
application/json: application/json:
schema: schema:
$ref: '#/components/schemas/ProjectActivityResponse' $ref: '#/components/schemas/ProjectActivityResponseEnvelope'
'400': '400':
description: '400' description: '400'
content: content:
application/problem+json: application/json:
schema: schema:
$ref: '#/components/schemas/ProblemDetails' $ref: '#/components/schemas/ErrorEnvelope'
'401': '401':
description: '401' description: '401'
content: content:
application/problem+json: application/json:
schema: schema:
$ref: '#/components/schemas/ProblemDetails' $ref: '#/components/schemas/ErrorEnvelope'
'403': '403':
description: '403' description: '403'
content: content:
application/problem+json: application/json:
schema: schema:
$ref: '#/components/schemas/ProblemDetails' $ref: '#/components/schemas/ErrorEnvelope'
'404': '404':
description: '404' description: '404'
content: content:
application/problem+json: application/json:
schema: schema:
$ref: '#/components/schemas/ProblemDetails' $ref: '#/components/schemas/ErrorEnvelope'
'409': '409':
description: '409' description: '409'
content: content:
application/problem+json: application/json:
schema: schema:
$ref: '#/components/schemas/ProblemDetails' $ref: '#/components/schemas/ErrorEnvelope'
'422': '422':
description: '422' description: '422'
content: content:
application/problem+json: application/json:
schema: schema:
$ref: '#/components/schemas/ProblemDetails' $ref: '#/components/schemas/ErrorEnvelope'
'500': '500':
description: '500' description: '500'
content: content:
application/problem+json: application/json:
schema: schema:
$ref: '#/components/schemas/ProblemDetails' $ref: '#/components/schemas/ErrorEnvelope'
delete:
operationId: deleteProjectActivity
tags:
- Projects
description: |-
프로젝트 활동 한 줄을 지운다. 계약에는 만들기와 고치기만 있었고 지우기가 없어서, 잘못
적은 줄이 공개 타임라인에 영구히 남았다.
`AUTO` 로 기록된 줄은 게시 같은 실제 사건의 흔적이므로 지우지 않는다 — 지우면 무슨 일이
있었는지가 사라진다. 손으로 적은 `MANUAL` 줄만 지울 수 있다.
parameters:
- name: id
in: path
required: true
schema:
type: string
format: uuid
- name: activityId
in: path
required: true
schema:
type: string
format: uuid
- $ref: '#/components/parameters/CsrfToken'
requestBody:
required: true
content:
application/json:
schema:
$ref: '#/components/schemas/ExpectedVersionRequest'
responses:
'204':
description: No Content
'400':
description: Bad Request
content:
application/json:
schema:
$ref: '#/components/schemas/ErrorEnvelope'
'401':
description: Unauthorized
content:
application/json:
schema:
$ref: '#/components/schemas/ErrorEnvelope'
'403':
description: Forbidden
content:
application/json:
schema:
$ref: '#/components/schemas/ErrorEnvelope'
'404':
description: Not Found
content:
application/json:
schema:
$ref: '#/components/schemas/ErrorEnvelope'
'409':
description: Conflict
content:
application/json:
schema:
$ref: '#/components/schemas/ErrorEnvelope'
'422':
description: Unprocessable Content
content:
application/json:
schema:
$ref: '#/components/schemas/ErrorEnvelope'
'500':
description: Internal Server Error
content:
application/json:
schema:
$ref: '#/components/schemas/ErrorEnvelope'
security:
- sessionCookie: []
components: components:
securitySchemes: securitySchemes:
sessionCookie: sessionCookie:
@@ -5510,6 +5585,53 @@ components:
$ref: '#/components/schemas/ReleaseEditResponse' $ref: '#/components/schemas/ReleaseEditResponse'
meta: meta:
$ref: '#/components/schemas/ResponseMeta' $ref: '#/components/schemas/ResponseMeta'
HomeFocusResponseEnvelope:
type: object
additionalProperties: false
required:
- success
- data
- meta
properties:
success:
type: boolean
const: true
data:
$ref: '#/components/schemas/HomeFocusResponse'
meta:
$ref: '#/components/schemas/ResponseMeta'
ProjectActivityResponseEnvelope:
type: object
additionalProperties: false
required:
- success
- data
- meta
properties:
success:
type: boolean
const: true
data:
$ref: '#/components/schemas/ProjectActivityResponse'
meta:
$ref: '#/components/schemas/ResponseMeta'
ProjectActivityListEnvelope:
type: object
additionalProperties: false
required:
- success
- data
- meta
properties:
success:
type: boolean
const: true
data:
type: array
items:
$ref: '#/components/schemas/ProjectActivityResponse'
meta:
$ref: '#/components/schemas/ResponseMeta'
PublishResponseEnvelope: PublishResponseEnvelope:
type: object type: object
additionalProperties: false additionalProperties: false
@@ -1,8 +1,8 @@
{ {
"packageId": "@tech-log/public-contract", "packageId": "@tech-log/public-contract",
"version": "2.1.0", "version": "2.1.0",
"digest": "sha256:37e6f804165de3e492e975075bea563ee41ae74076222a3562d3452631bfdb2b", "digest": "sha256:7eb668e39e279e49767306dd36e1dd51302071c39d78495d21307bbd9676220e",
"sourceRevision": "b195b29", "sourceRevision": "ef49d3a",
"operationIds": [ "operationIds": [
"getPublicSite", "getPublicSite",
"getPublicHome", "getPublicHome",
@@ -454,7 +454,7 @@ export interface components {
}; };
LatestEntry: { LatestEntry: {
/** @enum {string} */ /** @enum {string} */
entryType: "CASE" | "REFERENCE" | "PROJECT_ACTIVITY" | "RELEASE"; entryType: "CASE" | "REFERENCE" | "QUESTION" | "PROJECT_ACTIVITY" | "RELEASE";
title: string; title: string;
summary: string; summary: string;
path: string; path: string;
@@ -536,6 +536,7 @@ export interface components {
indexable: boolean; indexable: boolean;
case: { case: {
title: string; title: string;
summary?: string;
problemSummary: string; problemSummary: string;
conclusionSummary: string; conclusionSummary: string;
environmentSummary?: string[]; environmentSummary?: string[];
@@ -571,9 +572,15 @@ export interface components {
indexable: boolean; indexable: boolean;
reference: { reference: {
title: string; title: string;
summary?: string;
scopeSummary: string; scopeSummary: string;
appliesTo: string[]; appliesTo: string[];
excludedScope: string[]; excludedScope: string[];
rules?: {
title: string;
body: string;
}[];
examples?: string[];
/** @enum {string} */ /** @enum {string} */
freshnessStatus: "CURRENT" | "REVIEW_DUE" | "HISTORICAL"; freshnessStatus: "CURRENT" | "REVIEW_DUE" | "HISTORICAL";
content: string; content: string;
@@ -670,6 +677,9 @@ export interface components {
nextStep?: string; nextStep?: string;
systemOverviewMarkdown?: string; systemOverviewMarkdown?: string;
technologies?: string[]; technologies?: string[];
/** @description 이 프로젝트가 다루는 주제. 프로젝트 화면의 "주요 주제" 가 이 목록을 그린다.
* 화면은 처음부터 이 값을 읽고 있었지만 계약에 자리가 없어 늘 비어 있었다. */
topics?: components["schemas"]["TopicSummary"][];
/** Format: date-time */ /** Format: date-time */
updatedAt: string; updatedAt: string;
}; };
@@ -1022,6 +1022,7 @@ components:
enum: enum:
- CASE - CASE
- REFERENCE - REFERENCE
- QUESTION
- PROJECT_ACTIVITY - PROJECT_ACTIVITY
- RELEASE - RELEASE
title: title:
@@ -1239,6 +1240,12 @@ components:
properties: properties:
title: title:
type: string type: string
# 문서가 스스로 밝히는 한 줄 요약이다. 제목 바로 아래에 온다.
#
# 이 자리가 없어서 화면은 problemSummary / scopeSummary 를 대신 썼고, 그러면 머리말이
# 바로 아래의 "문제" 나 "이 기준을 쓰는 이유" 와 같은 글을 두 번 말한다.
summary:
type: string
problemSummary: problemSummary:
type: string type: string
conclusionSummary: conclusionSummary:
@@ -1325,6 +1332,12 @@ components:
properties: properties:
title: title:
type: string type: string
# 문서가 스스로 밝히는 한 줄 요약이다. 제목 바로 아래에 온다.
#
# 이 자리가 없어서 화면은 problemSummary / scopeSummary 를 대신 썼고, 그러면 머리말이
# 바로 아래의 "문제" 나 "이 기준을 쓰는 이유" 와 같은 글을 두 번 말한다.
summary:
type: string
scopeSummary: scopeSummary:
type: string type: string
appliesTo: appliesTo:
@@ -1335,6 +1348,23 @@ components:
type: array type: array
items: items:
type: string type: string
# Reference 의 본문은 `content` 마크다운이 아니라 이 두 칸에 있다. Studio 의 Reference
# 편집기는 규칙(제목+본문)과 예시를 따로 받고 body_markdown 은 비워 두므로, 이것을
# 내보내지 않으면 공개 화면에 판단 기준과 예시가 통째로 빠진다.
rules:
type: array
items:
type: object
required: [title, body]
properties:
title:
type: string
body:
type: string
examples:
type: array
items:
type: string
freshnessStatus: freshnessStatus:
type: string type: string
enum: enum:
@@ -1572,6 +1602,13 @@ components:
type: array type: array
items: items:
type: string type: string
topics:
type: array
description: |-
이 프로젝트가 다루는 주제. 프로젝트 화면의 "주요 주제" 가 이 목록을 그린다.
화면은 처음부터 이 값을 읽고 있었지만 계약에 자리가 없어 늘 비어 있었다.
items:
$ref: '#/components/schemas/TopicSummary'
updatedAt: *id003 updatedAt: *id003
featuredDecision: featuredDecision:
$ref: '#/components/schemas/RelatedEntry' $ref: '#/components/schemas/RelatedEntry'
@@ -1,8 +1,8 @@
{ {
"packageId": "@tech-log/studio-contract", "packageId": "@tech-log/studio-contract",
"version": "3.0.0", "version": "3.1.0",
"digest": "sha256:674327a82951fd4a1bc2594c858072dfcd0b9abe7c63198283da5d8c92a04326", "digest": "sha256:18dd46898be64b07f7e826409d19347512613ee2e22420028a4a0644f50f37dd",
"sourceRevision": "b195b29", "sourceRevision": "ef49d3a",
"operationIds": [ "operationIds": [
"getStudioSession", "getStudioSession",
"getStudioDashboard", "getStudioDashboard",
@@ -1074,7 +1074,32 @@ export interface components {
height: number | null; height: number | null;
decorative: boolean; decorative: boolean;
}; };
CaseRenderBlock: components["schemas"]["HeadingBlock"] | components["schemas"]["ParagraphBlock"] | components["schemas"]["BlockquoteBlock"] | components["schemas"]["UnorderedListBlock"] | components["schemas"]["OrderedListBlock"] | components["schemas"]["CodeBlock"] | components["schemas"]["DataTableBlock"] | components["schemas"]["CalloutBlock"] | components["schemas"]["EvidenceFigureBlock"]; /** @description `---` 로 쓴 구분선이다. 담을 내용이 없으므로 `type` 뿐이다.
* */
ThematicBreakBlock: {
/**
* @description discriminator enum property added by openapi-typescript
* @enum {string}
*/
type: "THEMATIC_BREAK";
};
/** @description `![alt](/media/...)` 로 쓴 그림이다.
*
* `EvidenceFigureBlock` 과 나누는 기준은 출처다. evidence 는 assetKey 로 가리켜 게시
* 시점에 고정되고 확대 보기를 갖지만, 이쪽은 작성자가 적은 경로를 그대로 쓴다. 경로 규칙은
* 링크와 같다 — 외부 스킴과 `javascript:` 는 거절한다.
* */
ImageBlock: {
/**
* @description discriminator enum property added by openapi-typescript
* @enum {string}
*/
type: "IMAGE";
src: string;
alt: string;
title: string | null;
};
CaseRenderBlock: components["schemas"]["HeadingBlock"] | components["schemas"]["ParagraphBlock"] | components["schemas"]["BlockquoteBlock"] | components["schemas"]["UnorderedListBlock"] | components["schemas"]["OrderedListBlock"] | components["schemas"]["CodeBlock"] | components["schemas"]["DataTableBlock"] | components["schemas"]["CalloutBlock"] | components["schemas"]["EvidenceFigureBlock"] | components["schemas"]["ThematicBreakBlock"] | components["schemas"]["ImageBlock"];
CasePublicRenderModel: components["schemas"]["PublicRenderModelBase"] & { CasePublicRenderModel: components["schemas"]["PublicRenderModelBase"] & {
/** @enum {string} */ /** @enum {string} */
kind: "CASE"; kind: "CASE";
@@ -1142,7 +1167,7 @@ export interface components {
/** @enum {string} */ /** @enum {string} */
status: "PROPOSED" | "ADOPTED"; status: "PROPOSED" | "ADOPTED";
/** Format: date */ /** Format: date */
decidedOn: string; decidedOn: string | null;
statement: string; statement: string;
rationale: string; rationale: string;
consequences: components["schemas"]["OrderedText"][]; consequences: components["schemas"]["OrderedText"][];
@@ -1,7 +1,7 @@
openapi: 3.1.0 openapi: 3.1.0
info: info:
title: Tech Log Studio API title: Tech Log Studio API
version: 3.0.0 version: 3.1.0
description: | description: |
Tech Log Studio orchestration 계약이다. Tech Log Studio orchestration 계약이다.
@@ -1321,7 +1321,10 @@ components:
properties: properties:
type: { type: string, enum: [HEADING] } type: { type: string, enum: [HEADING] }
id: { type: string, minLength: 1, maxLength: 200 } id: { type: string, minLength: 1, maxLength: 200 }
level: { type: integer, minimum: 2, maximum: 4 } # 작성자가 쓴 그대로 담는다. 서버 렌더러는 이 값을 2..4 로 좁혀 문서 안 제목 위계를
# 지키므로(BlockRenderer), 계약이 1..6 을 거절할 이유가 없다 — 거절하면 `#` 로 시작한
# 평범한 Markdown 이 통째로 렌더링되지 않는다.
level: { type: integer, minimum: 1, maximum: 6 }
content: { type: array, maxItems: 1000, items: { $ref: "#/components/schemas/Inline" } } content: { type: array, maxItems: 1000, items: { $ref: "#/components/schemas/Inline" } }
ParagraphBlock: ParagraphBlock:
type: object type: object
@@ -1452,6 +1455,29 @@ components:
width: { type: [integer, "null"], minimum: 1 } width: { type: [integer, "null"], minimum: 1 }
height: { type: [integer, "null"], minimum: 1 } height: { type: [integer, "null"], minimum: 1 }
decorative: { type: boolean } decorative: { type: boolean }
ThematicBreakBlock:
type: object
additionalProperties: false
description: |
`---` 로 쓴 구분선이다. 담을 내용이 없으므로 `type` 뿐이다.
required: [type]
properties:
type: { type: string, enum: [THEMATIC_BREAK] }
ImageBlock:
type: object
additionalProperties: false
description: |
`![alt](/media/...)` 로 쓴 그림이다.
`EvidenceFigureBlock` 과 나누는 기준은 출처다. evidence 는 assetKey 로 가리켜 게시
시점에 고정되고 확대 보기를 갖지만, 이쪽은 작성자가 적은 경로를 그대로 쓴다. 경로 규칙은
링크와 같다 — 외부 스킴과 `javascript:` 는 거절한다.
required: [type, src, alt, title]
properties:
type: { type: string, enum: [IMAGE] }
src: { type: string, minLength: 1, maxLength: 500 }
alt: { type: string, maxLength: 300 }
title: { type: [string, "null"], maxLength: 300 }
CaseRenderBlock: CaseRenderBlock:
oneOf: oneOf:
- { $ref: "#/components/schemas/HeadingBlock" } - { $ref: "#/components/schemas/HeadingBlock" }
@@ -1463,6 +1489,8 @@ components:
- { $ref: "#/components/schemas/DataTableBlock" } - { $ref: "#/components/schemas/DataTableBlock" }
- { $ref: "#/components/schemas/CalloutBlock" } - { $ref: "#/components/schemas/CalloutBlock" }
- { $ref: "#/components/schemas/EvidenceFigureBlock" } - { $ref: "#/components/schemas/EvidenceFigureBlock" }
- { $ref: "#/components/schemas/ThematicBreakBlock" }
- { $ref: "#/components/schemas/ImageBlock" }
discriminator: discriminator:
propertyName: type propertyName: type
mapping: mapping:
@@ -1475,6 +1503,8 @@ components:
DATA_TABLE: "#/components/schemas/DataTableBlock" DATA_TABLE: "#/components/schemas/DataTableBlock"
CALLOUT: "#/components/schemas/CalloutBlock" CALLOUT: "#/components/schemas/CalloutBlock"
EVIDENCE_FIGURE: "#/components/schemas/EvidenceFigureBlock" EVIDENCE_FIGURE: "#/components/schemas/EvidenceFigureBlock"
THEMATIC_BREAK: "#/components/schemas/ThematicBreakBlock"
IMAGE: "#/components/schemas/ImageBlock"
CasePublicRenderModel: CasePublicRenderModel:
unevaluatedProperties: false unevaluatedProperties: false
allOf: allOf:
@@ -1542,7 +1572,10 @@ components:
properties: properties:
kind: { type: string, enum: [PROJECT_DECISION] } kind: { type: string, enum: [PROJECT_DECISION] }
status: { type: string, enum: [PROPOSED, ADOPTED] } status: { type: string, enum: [PROPOSED, ADOPTED] }
decidedOn: { type: string, format: date } # 결정일은 비어 있을 수 있다. 검증은 이것을 경고로만 다루므로(DECIDED_ON_REQUIRED)
# 날짜 없이 게시할 수 있는데, 렌더 모델이 필수로 요구하면 그 문서는 미리보기조차
# 열리지 않는다 — 두 규칙이 어긋나면 작성자는 "경고라며 왜 안 되냐"를 만난다.
decidedOn: { type: [string, "null"], format: date }
statement: { type: string, maxLength: 100000 } statement: { type: string, maxLength: 100000 }
rationale: { type: string, maxLength: 100000 } rationale: { type: string, maxLength: 100000 }
consequences: { type: array, maxItems: 50, items: { $ref: "#/components/schemas/OrderedText" } } consequences: { type: array, maxItems: 50, items: { $ref: "#/components/schemas/OrderedText" } }
@@ -310,6 +310,103 @@ const HTTP_CONTRACTS = Object.freeze([
}); });
}, },
), ),
readOperation("listStudioProjectActivities", `${P}/{id}/activities`, 262_144, byId),
writeOperation(
"createProjectActivity",
"POST",
`${P}/{id}/activities`,
{ acceptedStatuses: [201], requestByteLimit: 16_384, responseByteLimit: 16_384 },
(input: never) => {
const value = input as unknown as Readonly<{ id: string; body: unknown }>;
return Object.freeze({
pathValues: Object.freeze({ id: value.id }),
queryEntries: NO_QUERY,
body: value.body,
});
},
),
writeOperation(
"updateProjectActivity",
"PUT",
`${P}/{id}/activities/{activityId}`,
{ acceptedStatuses: [200], requestByteLimit: 16_384, responseByteLimit: 16_384 },
(input: never) => {
const value = input as unknown as Readonly<{
id: string;
activityId: string;
body: unknown;
}>;
return Object.freeze({
pathValues: Object.freeze({ id: value.id, activityId: value.activityId }),
queryEntries: NO_QUERY,
body: value.body,
});
},
),
writeOperation(
"deleteProjectActivity",
"DELETE",
`${P}/{id}/activities/{activityId}`,
{
acceptedStatuses: [204],
emptyBodyStatuses: [204],
requestByteLimit: 1_024,
responseByteLimit: 1_024,
},
(input: never) => {
const value = input as unknown as Readonly<{
id: string;
activityId: string;
expectedVersion: number;
}>;
return Object.freeze({
pathValues: Object.freeze({ id: value.id, activityId: value.activityId }),
queryEntries: NO_QUERY,
body: { expectedVersion: value.expectedVersion },
});
},
),
writeOperation(
"publishProject",
"POST",
`${P}/{id}/publish`,
{ acceptedStatuses: [200], requestByteLimit: 1_024, responseByteLimit: 8_192 },
(input: never) => {
const value = input as unknown as Readonly<{
id: string;
expectedVersion: number;
visibility: "PUBLIC" | "UNLISTED";
}>;
return Object.freeze({
pathValues: Object.freeze({ id: value.id }),
queryEntries: NO_QUERY,
body: { expectedVersion: value.expectedVersion, visibility: value.visibility },
});
},
),
writeOperation(
"unpublishProject",
"POST",
`${P}/{id}/unpublish`,
{ acceptedStatuses: [200], requestByteLimit: 1_024, responseByteLimit: 262_144 },
(input: never) => {
const value = input as unknown as Readonly<{ id: string; expectedVersion: number }>;
return Object.freeze({
pathValues: Object.freeze({ id: value.id }),
queryEntries: NO_QUERY,
body: { expectedVersion: value.expectedVersion },
});
},
),
readOperation("getHomeFocus", `${D}/home/focus`, 8_192),
writeOperation(
"updateHomeFocus",
"PUT",
`${D}/home/focus`,
{ acceptedStatuses: [200], requestByteLimit: 4_096, responseByteLimit: 8_192 },
(input: never) =>
Object.freeze({ pathValues: NO_PATH, queryEntries: NO_QUERY, body: input }),
),
writeOperation( writeOperation(
"publishRelease", "publishRelease",
"POST", "POST",
@@ -49,7 +49,9 @@ const TECH_LOG_ROUTE_SPECS = [
defineSpec({ routeId: "TECH_LOG_STUDIO_PUBLICATION_PREVIEW", path: "/studio/publications/:publicationEventId/preview", layoutGroup: "STUDIO", paramsSchema: "TechLogPublicationEventIdParams", searchSchema: null, title: "게시 Snapshot", navigationLabel: null, navigationOrder: null }), defineSpec({ routeId: "TECH_LOG_STUDIO_PUBLICATION_PREVIEW", path: "/studio/publications/:publicationEventId/preview", layoutGroup: "STUDIO", paramsSchema: "TechLogPublicationEventIdParams", searchSchema: null, title: "게시 Snapshot", navigationLabel: null, navigationOrder: null }),
defineSpec({ routeId: "TECH_LOG_STUDIO_ASSETS", path: "/studio/assets", layoutGroup: "STUDIO", paramsSchema: null, searchSchema: null, title: "Asset", navigationLabel: null, navigationOrder: null }), defineSpec({ routeId: "TECH_LOG_STUDIO_ASSETS", path: "/studio/assets", layoutGroup: "STUDIO", paramsSchema: null, searchSchema: null, title: "Asset", navigationLabel: null, navigationOrder: null }),
defineSpec({ routeId: "TECH_LOG_STUDIO_TAXONOMY", path: "/studio/taxonomy", layoutGroup: "STUDIO", paramsSchema: null, searchSchema: null, title: "주제와 프로젝트", navigationLabel: "주제·프로젝트", navigationOrder: 40 }), defineSpec({ routeId: "TECH_LOG_STUDIO_TAXONOMY", path: "/studio/taxonomy", layoutGroup: "STUDIO", paramsSchema: null, searchSchema: null, title: "주제와 프로젝트", navigationLabel: "주제·프로젝트", navigationOrder: 40 }),
defineSpec({ routeId: "TECH_LOG_STUDIO_PROJECT_EDIT", path: "/studio/projects/:id", layoutGroup: "STUDIO", paramsSchema: "TechLogDocumentIdParams", searchSchema: null, title: "프로젝트 편집", navigationLabel: null, navigationOrder: null }),
defineSpec({ routeId: "TECH_LOG_STUDIO_RELEASES", path: "/studio/releases", layoutGroup: "STUDIO", paramsSchema: null, searchSchema: null, title: "릴리즈", navigationLabel: "릴리즈", navigationOrder: 50 }), defineSpec({ routeId: "TECH_LOG_STUDIO_RELEASES", path: "/studio/releases", layoutGroup: "STUDIO", paramsSchema: null, searchSchema: null, title: "릴리즈", navigationLabel: "릴리즈", navigationOrder: 50 }),
defineSpec({ routeId: "TECH_LOG_STUDIO_RELEASE_EDIT", path: "/studio/releases/:id", layoutGroup: "STUDIO", paramsSchema: "TechLogDocumentIdParams", searchSchema: null, title: "릴리즈 편집", navigationLabel: null, navigationOrder: null }),
defineSpec({ routeId: "TECH_LOG_STUDIO_NOT_FOUND", path: "/studio/*", layoutGroup: "STUDIO", paramsSchema: "TechLogStudioSplat", searchSchema: null, title: "Studio 화면을 찾을 수 없습니다", navigationLabel: null, navigationOrder: null }), defineSpec({ routeId: "TECH_LOG_STUDIO_NOT_FOUND", path: "/studio/*", layoutGroup: "STUDIO", paramsSchema: "TechLogStudioSplat", searchSchema: null, title: "Studio 화면을 찾을 수 없습니다", navigationLabel: null, navigationOrder: null }),
defineSpec({ routeId: "NOT_FOUND", path: "*", layoutGroup: "PUBLIC", paramsSchema: "NotFoundSplat", searchSchema: null, title: "페이지를 찾을 수 없습니다.", navigationLabel: null, navigationOrder: null }), defineSpec({ routeId: "NOT_FOUND", path: "*", layoutGroup: "PUBLIC", paramsSchema: "NotFoundSplat", searchSchema: null, title: "페이지를 찾을 수 없습니다.", navigationLabel: null, navigationOrder: null }),
] as const; ] as const;
@@ -180,14 +180,27 @@ function normalizeDirectives(source: string): string {
return line; return line;
} }
/*
`:::name key="value"` remark-directive `:::name{key="value"}` .
`\s` .
`[a-z0-9-]*` `{` ,
`:::note` `not`
`e` `:::not{e}` , `:::table{id="t"}`
`:::tabl{e{id="t"}}` . ,
.
*/
return line.replace( return line.replace(
/^(:::[a-z][a-z0-9-]*)([^\n{][^\n]*)(\n?)$/i, /^(:::[a-z][a-z0-9-]*)(\s+[^\n]*?)(\n?)$/i,
( (
_match, _match,
marker: string, marker: string,
attributes: string, attributes: string,
newline: string, newline: string,
) => `${marker}{${attributes.trim()}}${newline}`, ) => {
const trimmed = attributes.trim();
return trimmed ? `${marker}{${trimmed}}${newline}` : `${marker}${newline}`;
},
); );
}) })
.join(""); .join("");
@@ -199,6 +212,14 @@ function assertNever(value: never): never {
const trustedRelativeLinkOrigin = "https://techlog.invalid"; const trustedRelativeLinkOrigin = "https://techlog.invalid";
/** 서버가 아는 callout 이름과 화면에 붙일 말. 이름이 tone 을 겸하므로 속성을 받지 않는다. */
const CALLOUT_LABELS: Readonly<Record<string, string>> = {
note: "참고",
tip: "도움말",
warning: "주의",
danger: "위험",
};
function hasAsciiControlCharacter(value: string): boolean { function hasAsciiControlCharacter(value: string): boolean {
return Array.from(value).some((character) => { return Array.from(value).some((character) => {
const codePoint = character.codePointAt(0) ?? 0; const codePoint = character.codePointAt(0) ?? 0;
@@ -326,8 +347,8 @@ function headingBlock(
node: Heading, node: Heading,
usedIds: Set<string>, usedIds: Set<string>,
): components["schemas"]["HeadingBlock"] { ): components["schemas"]["HeadingBlock"] {
if (node.depth < 2 || node.depth > 4) { if (node.depth < 1 || node.depth > 6) {
invalid(node, "only heading levels 2 through 4 are supported"); invalid(node, "only heading levels 1 through 6 are supported");
} }
const children = [...node.children]; const children = [...node.children];
@@ -417,15 +438,34 @@ function tableCellContent(cell: TableCell): Inline[] {
return inlineFromNodes(cell.children); return inlineFromNodes(cell.children);
} }
/**
* `:::table` GFM .
*
* <p> `:::table` directive .
* Studio ,
* . `id` `caption` . `:::table`
* `caption` .
*/
function bareTableAttributes(index: number): Record<string, string> {
return { id: `table-${index}`, caption: "", rowHeaderColumn: "none" };
}
function tableBlock( function tableBlock(
node: ContainerDirective, node: ContainerDirective | Table,
usedIds: Set<string>, usedIds: Set<string>,
bareIndex?: number,
): components["schemas"]["DataTableBlock"] { ): components["schemas"]["DataTableBlock"] {
const attributes = attributesOf(node, ["id", "caption", "rowHeaderColumn"]); const bare = node.type === "table";
if (node.children.length !== 1 || node.children[0].type !== "table") { const attributes = bare
? bareTableAttributes(bareIndex!)
: attributesOf(node as ContainerDirective, ["id", "caption", "rowHeaderColumn"]);
if (!bare) {
const container = node as ContainerDirective;
if (container.children.length !== 1 || container.children[0].type !== "table") {
invalid(node, "table directive must contain exactly one GFM table"); invalid(node, "table directive must contain exactly one GFM table");
} }
const table = node.children[0] as Table; }
const table = (bare ? node : (node as ContainerDirective).children[0]) as Table;
if (table.children.length === 0) invalid(table, "table header is required"); if (table.children.length === 0) invalid(table, "table header is required");
const id = attributes.id; const id = attributes.id;
@@ -506,6 +546,25 @@ function directiveBlock(
content: inlineFromNodes((node.children[0] as Paragraph).children), content: inlineFromNodes((node.children[0] as Paragraph).children),
}; };
} }
/*
callout (`note`/`tip` , `warning`/`danger` ).
`:::callout tone="..."`
. tone , .
*/
case "note":
case "tip":
case "warning":
case "danger": {
if (node.children.length !== 1 || node.children[0].type !== "paragraph") {
invalid(node, `${node.name} directive must contain exactly one paragraph`);
}
return {
type: "CALLOUT",
tone: node.name === "note" || node.name === "tip" ? "info" : "warning",
label: CALLOUT_LABELS[node.name],
content: inlineFromNodes((node.children[0] as Paragraph).children),
};
}
case "evidence": { case "evidence": {
const attributes = attributesOf(node, ["key", "alt", "caption", "zoom"]); const attributes = attributesOf(node, ["key", "alt", "caption", "zoom"]);
if (node.children.length !== 0) { if (node.children.length !== 0) {
@@ -533,10 +592,34 @@ function directiveBlock(
} }
} }
/**
* .
*
* <p>Markdown `![alt](/media/...)` inline .
* .
* "문단이 그림 하나로만 이루어진 경우" .
*/
function imageBlockOf(node: Paragraph): components["schemas"]["ImageBlock"] | null {
const visible = node.children.filter(
(child) => !(child.type === "text" && child.value.trim() === ""),
);
if (visible.length !== 1) return null;
const only = visible[0]!;
if (only.type !== "image") return null;
const image = only as unknown as { url: string; alt?: string | null; title?: string | null };
if (!isSafeLink(image.url)) invalid(node, `unsafe image URL: ${image.url}`);
return {
type: "IMAGE",
src: image.url,
alt: image.alt ?? "",
title: image.title ?? null,
};
}
function paragraphBlock( function paragraphBlock(
node: Paragraph, node: Paragraph,
): components["schemas"]["ParagraphBlock"] { ): components["schemas"]["ParagraphBlock"] | components["schemas"]["ImageBlock"] {
return { type: "PARAGRAPH", content: inlineFromNodes(node.children) }; return imageBlockOf(node) ?? { type: "PARAGRAPH", content: inlineFromNodes(node.children) };
} }
export function parseCaseContent(source: string): CaseAuthoringBlock[] { export function parseCaseContent(source: string): CaseAuthoringBlock[] {
@@ -564,6 +647,7 @@ export function parseCaseContent(source: string): CaseAuthoringBlock[] {
listItemCount += 1; listItemCount += 1;
return `list-item-${listItemCount}`; return `list-item-${listItemCount}`;
}; };
let bareTableCount = 0;
return tree.children.map((node: Content): CaseAuthoringBlock => { return tree.children.map((node: Content): CaseAuthoringBlock => {
switch (node.type) { switch (node.type) {
@@ -585,11 +669,17 @@ export function parseCaseContent(source: string): CaseAuthoringBlock[] {
return codeBlock(node); return codeBlock(node);
case "containerDirective": case "containerDirective":
return directiveBlock(node, usedIds); return directiveBlock(node, usedIds);
case "html": case "table": {
// 서버 렌더러는 파이프 표를 그대로 읽는다. 여기서 거절하면 같은 본문이 Studio 와
// 공개 화면에서 다르게 읽힌다.
bareTableCount += 1;
return tableBlock(node, usedIds, bareTableCount);
}
case "thematicBreak": case "thematicBreak":
return { type: "THEMATIC_BREAK" };
case "html":
case "definition": case "definition":
case "yaml": case "yaml":
case "table":
case "footnoteDefinition": case "footnoteDefinition":
case "leafDirective": case "leafDirective":
return invalid(node, `unsupported block syntax: ${node.type}`); return invalid(node, `unsupported block syntax: ${node.type}`);
@@ -131,7 +131,18 @@ function renderContext(
function publicPath(input: WorkingCopyInput, project: CatalogEntry | null) { function publicPath(input: WorkingCopyInput, project: CatalogEntry | null) {
if (input.kind === "PROJECT_DECISION") { if (input.kind === "PROJECT_DECISION") {
if (!project?.publicPath) fail("PROJECT public path is required"); /*
Decision slug .
.
"PROJECT public path is required" .
.
*/
if (!project?.publicPath) {
fail(
"이 Decision 의 공개 주소는 프로젝트 주소 아래에 있습니다. 주제·프로젝트 화면에서 이 프로젝트를 먼저 게시해 주세요.",
);
}
return `${project.publicPath}/decisions#${input.slug}`; return `${project.publicPath}/decisions#${input.slug}`;
} }
const prefix = const prefix =
@@ -186,7 +197,7 @@ export function projectWorkingCopy(
const project = catalogEntry(catalog, input.projectId, "PROJECT", false); const project = catalogEntry(catalog, input.projectId, "PROJECT", false);
if (!topic) fail("TOPIC catalog entry is required"); if (!topic) fail("TOPIC catalog entry is required");
if (input.kind === "PROJECT_DECISION" && !project) { if (input.kind === "PROJECT_DECISION" && !project) {
fail("PROJECT catalog entry is required"); fail("Decision 은 프로젝트에 속합니다. 기본 정보에서 프로젝트를 골라 주세요.");
} }
const base = { const base = {
@@ -273,12 +284,16 @@ export function projectWorkingCopy(
case "PROJECT_DECISION": case "PROJECT_DECISION":
if (!input.decisionStatus) fail("Decision status is required"); if (!input.decisionStatus) fail("Decision status is required");
if (!input.decidedOn) fail("Decision date is required"); /*
.
, "
"를 만난다. 화면이 "" .
*/
return { return {
...base, ...base,
kind: "PROJECT_DECISION", kind: "PROJECT_DECISION",
status: input.decisionStatus, status: input.decisionStatus,
decidedOn: input.decidedOn, decidedOn: input.decidedOn ?? null,
statement: input.statement, statement: input.statement,
rationale: input.rationale, rationale: input.rationale,
consequences: ordered(input.consequences), consequences: ordered(input.consequences),
@@ -188,6 +188,14 @@ function serializeBlock(block: CaseRenderBlock): string {
`:::evidence key=${quoteAttribute(block.key)} alt=${quoteAttribute(block.alt)} caption=${quoteAttribute(block.caption)} zoom=${quoteAttribute(String(block.zoom))}`, `:::evidence key=${quoteAttribute(block.key)} alt=${quoteAttribute(block.alt)} caption=${quoteAttribute(block.caption)} zoom=${quoteAttribute(String(block.zoom))}`,
":::", ":::",
].join("\n"); ].join("\n");
case "THEMATIC_BREAK":
return "---";
case "IMAGE":
// 제목은 Markdown 이 따옴표로 감싼다. 없으면 붙이지 않아야 다시 읽었을 때 빈 제목이 되지
// 않는다.
return block.title === null
? `![${escapeText(block.alt)}](${block.src})`
: `![${escapeText(block.alt)}](${block.src} "${block.title.replaceAll('"', '\\"')}")`;
default: default:
return assertNever(block); return assertNever(block);
} }
@@ -11,3 +11,28 @@ export function createLocalId(
const entropy = Math.floor(random() * 1_000_000_000).toString().padStart(9, "0"); const entropy = Math.floor(random() * 1_000_000_000).toString().padStart(9, "0");
return `${prefix}-${now()}-${entropy}`; return `${prefix}-${now()}-${entropy}`;
} }
/**
* (···· ) id.
*
* `createLocalId` .
* `relation-<uuid>` , React key Idempotency-Key
* uuid ,
* 400 (`InvalidFormatException`) . "계약을 어겼다"
* .
*
* id ({@code StudioRelationStore.replace}).
* "이 줄은 새것" , id .
*/
export function createNewItemId(
source: RandomUuidSource | null | undefined = globalThis.crypto,
random: () => number = Math.random,
): string {
const uuid = source?.randomUUID?.();
if (uuid) return uuid;
// randomUUID 가 없는 환경(비보안 컨텍스트)을 위한 대비. 형식만 uuid v4 를 지키면 된다 — 값 자체는
// 서버가 어차피 새로 부여한다.
const hex = (length: number) =>
Array.from({ length }, () => Math.floor(random() * 16).toString(16)).join("");
return `${hex(8)}-${hex(4)}-4${hex(3)}-${"89ab"[Math.floor(random() * 4)]}${hex(3)}-${hex(12)}`;
}
@@ -37,7 +37,18 @@ export function ExploreFilterForm({
); );
const resolved = await Promise.all(projectSlugs.map((slug) => queries.getProject(slug))); const resolved = await Promise.all(projectSlugs.map((slug) => queries.getProject(slug)));
return { return {
topics: [...new Set(records.map((record) => record.topic))].sort(), // 주제는 이름이 아니라 slug 로 거른다 — 프로젝트와 같다. 이름을 실었을 때는
// `topic=OAuth/OIDC 인증 경계` 가 나갔고, slug 로 거르는 API 는 0건을 돌려줬다.
// 화면에 보일 이름과 보낼 slug 가 다르므로 짝으로 들고 있어야 한다.
topics: [
...new Map(
records
.filter((record) => record.topicSlug)
.map((record) => [record.topicSlug, record.topic] as const),
),
]
.map(([slug, name]) => ({ slug, name }))
.sort((left, right) => left.name.localeCompare(right.name, "ko-KR")),
projects: resolved projects: resolved
.filter((item) => item !== undefined) .filter((item) => item !== undefined)
.map((item) => ({ slug: item.slug, title: item.title })), .map((item) => ({ slug: item.slug, title: item.title })),
@@ -47,8 +58,10 @@ export function ExploreFilterForm({
const projects = view.data?.projects ?? []; const projects = view.data?.projects ?? [];
const normalizedTopic = topic?.toLocaleLowerCase("ko-KR"); const normalizedTopic = topic?.toLocaleLowerCase("ko-KR");
const selectedTopic = topics.find( const selectedTopic = topics.find(
(item) => item.toLocaleLowerCase("ko-KR") === normalizedTopic, (item) =>
); item.slug.toLocaleLowerCase("ko-KR") === normalizedTopic ||
item.name.toLocaleLowerCase("ko-KR") === normalizedTopic,
)?.slug;
const normalizedProject = project?.toLocaleLowerCase("ko-KR"); const normalizedProject = project?.toLocaleLowerCase("ko-KR");
const selectedProject = projects.find( const selectedProject = projects.find(
(item) => (item) =>
@@ -67,7 +80,7 @@ export function ExploreFilterForm({
const data = new FormData(event.currentTarget); const data = new FormData(event.currentTarget);
const search = new URLSearchParams(); const search = new URLSearchParams();
for (const [key, value] of data) { for (const [key, value] of data) {
if (typeof value === "string") search.append(key, value); if (typeof value === "string" && value !== "") search.append(key, value);
} }
void navigate(`${action}?${search.toString()}`); void navigate(`${action}?${search.toString()}`);
} }
@@ -83,7 +96,7 @@ export function ExploreFilterForm({
{showType ? ( {showType ? (
<label><span></span><select name="type" defaultValue={kind ?? ""}><option value=""></option><option value="CASE">Case</option><option value="REFERENCE">Reference</option><option value="QUESTION">Open Question</option></select></label> <label><span></span><select name="type" defaultValue={kind ?? ""}><option value=""></option><option value="CASE">Case</option><option value="REFERENCE">Reference</option><option value="QUESTION">Open Question</option></select></label>
) : null} ) : null}
<label><span></span><select name="topic" defaultValue={selectedTopic ?? ""}><option value=""></option>{topics.map((item) => <option key={item}>{item}</option>)}</select></label> <label><span></span><select name="topic" defaultValue={selectedTopic ?? ""}><option value=""></option>{topics.map((item) => <option value={item.slug} key={item.slug}>{item.name}</option>)}</select></label>
<label><span></span><select name="project" defaultValue={selectedProject ?? ""}><option value=""></option>{projects.map((item) => <option value={item.slug} key={item.slug}>{item.title}</option>)}</select></label> <label><span></span><select name="project" defaultValue={selectedProject ?? ""}><option value=""></option>{projects.map((item) => <option value={item.slug} key={item.slug}>{item.title}</option>)}</select></label>
<button type="submit"></button> <button type="submit"></button>
{hasActiveFilter ? <Link to={action}> </Link> : null} {hasActiveFilter ? <Link to={action}> </Link> : null}
@@ -19,7 +19,13 @@ export function PublicDocumentHeader({ record }: { record: PublicRecord }) {
{kindLabels[record.kind]} {kindLabels[record.kind]}
</Link> </Link>
<span aria-hidden="true">/</span> <span aria-hidden="true">/</span>
<Link to={`/topics/${record.topicSlug}`}>{record.topic}</Link> {/*
. `/topics/:slug`
404 , ··
.
.
*/}
<Link to={`/explore?topic=${encodeURIComponent(record.topicSlug)}`}>{record.topic}</Link>
<span aria-hidden="true">/</span> <span aria-hidden="true">/</span>
<Link to={`/projects/${record.projectSlug}`}> <Link to={`/projects/${record.projectSlug}`}>
{record.projectTitle} {record.projectTitle}
@@ -57,7 +63,9 @@ export function publicRenderModelBase(
topic: { topic: {
id: `topic-${record.topicSlug}`, id: `topic-${record.topicSlug}`,
label: record.topic, label: record.topic,
publicPath: `/topics/${record.topicSlug}`, // 공개 문서의 머리말이 실제로 그리는 주제 링크는 이 값이다 — 위의 breadcrumb 과 같은
// 이유로 탐색 필터를 가리킨다. 둘 중 하나만 고치면 화면에서는 그대로 404 로 간다.
publicPath: `/explore?topic=${encodeURIComponent(record.topicSlug)}`,
}, },
project: { project: {
id: `project-${record.projectSlug}`, id: `project-${record.projectSlug}`,
@@ -39,56 +39,43 @@ function optionalString(value: unknown): string | undefined {
return typeof value === "string" ? value : undefined; return typeof value === "string" ? value : undefined;
} }
/**
* .
*
* .
* Case
* .
* . N+1 .
*
* Publication .
* .
*/
const latestTypeLabels: Readonly<Record<string, string>> = {
PROJECT_ACTIVITY: "PROJECT ACTIVITY",
QUESTION: "OPEN QUESTION",
};
async function getLatestEntries( async function getLatestEntries(
publicContent: PublicContentQueries, publicContent: PublicContentQueries,
): Promise<LatestEntry[]> { ): Promise<LatestEntry[]> {
const publicRecords = await publicContent.listRecords(); const [records, searchableEntities] = await Promise.all([
const publicRecordByPath = new Map( publicContent.getLatestEntries(),
publicRecords.map((record) => [record.path, record]), publicContent.searchPublicContent(""),
); ]);
const searchableEntities = await publicContent.searchPublicContent("");
const projectPrefix = "/projects/"; const recordTimeline: LatestEntry[] = records.map((entry) => ({
const projectSlugs = searchableEntities id: entry.id,
.filter((entity) => entity.contentType === "PROJECT") // 목록의 다른 이름들과 같은 자리에 놓이므로 표기도 같은 규칙을 쓴다 — 대문자에 공백.
.flatMap((entity) => typeLabel: latestTypeLabels[entry.entryType] ?? entry.entryType,
entity.path.startsWith(projectPrefix) title: entry.title,
? [decodeURIComponent(entity.path.slice(projectPrefix.length))] summary: entry.summary,
: [], date: dateLabel(entry.publishedAt),
); dateTime: entry.publishedAt,
// One project at a time would serialise a request per project; issuing them topic: entry.topic,
// together keeps the timeline's cost at its slowest project rather than their project: entry.project,
// sum. The flatten below restores the original single-list shape. path: entry.path,
const projectTimeline = ( }));
await Promise.all(
projectSlugs.map(async (projectSlug) => {
const project = await publicContent.getProject(projectSlug);
if (!project) return [];
const activities = await publicContent.getProjectActivity(projectSlug);
return activities.map((activity) => {
const record = publicRecordByPath.get(
activity.recordPath ?? activity.path,
);
return {
id: activity.id,
typeLabel:
activity.type === "PUBLICATION" && record
? record.kind
: "PROJECT ACTIVITY",
title:
activity.type === "PUBLICATION" && record
? record.title
: activity.title,
summary: activity.summary,
date: activity.date,
dateTime: activity.dateTime,
topic: record?.topic ?? project.topics[0] ?? "",
project: project.title,
path: activity.path,
};
});
}),
)
).flat();
const releaseTimeline = ( const releaseTimeline = (
await Promise.all( await Promise.all(
searchableEntities searchableEntities
@@ -117,11 +104,20 @@ async function getLatestEntries(
) )
).flat(); ).flat();
return [...projectTimeline, ...releaseTimeline].sort((left, right) => return [...recordTimeline, ...releaseTimeline].sort((left, right) =>
right.dateTime.localeCompare(left.dateTime), right.dateTime.localeCompare(left.dateTime),
); );
} }
/** 목록의 날짜 칸은 공개 화면 어디서나 같은 형식이다. */
function dateLabel(isoTimestamp: string): string {
const parsed = new Date(isoTimestamp);
if (Number.isNaN(parsed.getTime())) return "";
return `${parsed.getFullYear()}.${String(parsed.getMonth() + 1).padStart(2, "0")}.${String(
parsed.getDate(),
).padStart(2, "0")}`;
}
export function HomePage() { export function HomePage() {
const { search } = useRouteInput<"TECH_LOG_HOME">(); const { search } = useRouteInput<"TECH_LOG_HOME">();
const requestedKey = optionalString(search.focus); const requestedKey = optionalString(search.focus);
@@ -1,5 +1,3 @@
import { Link } from "react-router-dom";
import { import {
RegisteredNotFoundRoute, RegisteredNotFoundRoute,
useRouteInput, useRouteInput,
@@ -21,9 +19,19 @@ export function ProjectActivityPage() {
const { project, activity } = view.data; const { project, activity } = view.data;
if (!project) return <RegisteredNotFoundRoute />; if (!project) return <RegisteredNotFoundRoute />;
/*
.
. ,
"기록" "활동" .
.
*/
return ( return (
<main id="main-content" className="shell project-page"> <main id="main-content" className="shell project-page">
<ProjectPageHeader project={project} title={`${project.title} 활동`} /> <ProjectPageHeader project={project} title={`${project.title} 활동`} />
{activity.length === 0 ? (
<p className="public-empty-note"> .</p>
) : (
<ol className="project-activity-list"> <ol className="project-activity-list">
{activity.map((item) => ( {activity.map((item) => (
<li key={item.id}> <li key={item.id}>
@@ -33,16 +41,11 @@ export function ProjectActivityPage() {
<time dateTime={item.dateTime}>{item.date}</time> <time dateTime={item.dateTime}>{item.date}</time>
</div> </div>
<h2>{item.title}</h2> <h2>{item.title}</h2>
<p>{item.summary}</p>
<Link to={item.recordPath ?? item.path}>
{item.recordPath
? "연결된 공개 기록 읽기"
: "이 활동 위치 열기"}
</Link>
</article> </article>
</li> </li>
))} ))}
</ol> </ol>
)}
</main> </main>
); );
} }
@@ -38,10 +38,11 @@ export function TopicPage() {
const topic = topicConfig(params.slug); const topic = topicConfig(params.slug);
// Hooks run unconditionally, so the unknown-topic case is handled by the // Hooks run unconditionally, so the unknown-topic case is handled by the
// loader and the not-found route is chosen after it. // loader and the not-found route is chosen after it.
const slug = typeof params.slug === "string" ? params.slug : "";
const view = usePublicContent( const view = usePublicContent(
["tech-log", "topic", topic?.title], ["tech-log", "topic", slug],
async (queries) => async (queries) =>
topic ? { records: await queries.listRecords({ topic: topic.title }) } : { records: [] }, topic ? { records: await queries.listRecords({ topic: slug }) } : { records: [] },
); );
if (!topic) return <RegisteredNotFoundRoute />; if (!topic) return <RegisteredNotFoundRoute />;
if (!view.ready) return view.fallback; if (!view.ready) return view.fallback;
@@ -99,6 +99,19 @@ function renderBlock(
resolveEvidenceAsset={resolveEvidenceAsset} resolveEvidenceAsset={resolveEvidenceAsset}
/> />
); );
case "THEMATIC_BREAK":
return <hr key={key} className="document-rule" />;
case "IMAGE":
/*
. (`isSafeLink`).
`title` Markdown .
*/
return (
<figure key={key} className="document-image">
<img src={block.src} alt={block.alt} loading="lazy" />
{block.title ? <figcaption>{block.title}</figcaption> : null}
</figure>
);
default: default:
return assertNever(block); return assertNever(block);
} }
@@ -12,9 +12,20 @@ type DocumentTocProps = {
export function DocumentToc({ headings, variant }: DocumentTocProps) { export function DocumentToc({ headings, variant }: DocumentTocProps) {
const [currentId, setCurrentId] = useState(headings[0]?.id ?? ""); const [currentId, setCurrentId] = useState(headings[0]?.id ?? "");
const empty = headings.length === 0;
const detailsRef = useRef<HTMLDetailsElement>(null); const detailsRef = useRef<HTMLDetailsElement>(null);
useEffect(() => { useEffect(() => {
/*
`IntersectionObserver` jsdom , .
"지금 읽는 절" ,
.
Case . Case
.
*/
if (typeof IntersectionObserver === "undefined") return undefined;
const elements = headings const elements = headings
.map((heading) => document.getElementById(heading.id)) .map((heading) => document.getElementById(heading.id))
.filter((element): element is HTMLElement => Boolean(element)); .filter((element): element is HTMLElement => Boolean(element));
@@ -41,6 +52,12 @@ export function DocumentToc({ headings, variant }: DocumentTocProps) {
if (detailsRef.current) detailsRef.current.open = false; if (detailsRef.current) detailsRef.current.open = false;
} }
/*
.
, Case .
*/
if (empty) return null;
if (variant === "mobile") { if (variant === "mobile") {
const current = const current =
headings.find((heading) => heading.id === currentId) ?? headings[0]; headings.find((heading) => heading.id === currentId) ?? headings[0];
@@ -128,57 +128,18 @@ function publicRelations(
})); }));
} }
function GenericCase({
model,
embedded,
resolveEvidenceAsset,
resolvePublishedLabel,
}: {
model: CasePublicRenderModel;
embedded: boolean;
} & RenderDependencies) {
const Root = embedded ? "div" : "main";
return (
<Root
id={embedded ? undefined : "main-content"}
className={`shell public-document-page${embedded ? " public-record-embedded" : ""}`}
>
<ModelDocumentHeader
model={model}
resolvePublishedLabel={resolvePublishedLabel}
/>
<section className="document-snapshot" aria-label="문제와 결론">
<div>
<p className="snapshot-label"></p>
<p><PlainText text={model.problem} /></p>
</div>
<div>
<p className="snapshot-label snapshot-label--answer"></p>
<p><PlainText text={model.conclusion} /></p>
</div>
</section>
<dl className="document-facts">
<div>
<dt> </dt>
<dd><PlainText text={model.environment} /></dd>
</div>
<div>
<dt> </dt>
<dd><PlainText text={model.reproduction} /></dd>
</div>
</dl>
<article className="public-document-body">
<CaseBodyRenderer
blocks={model.bodyBlocks}
resolveEvidenceAsset={resolveEvidenceAsset}
/>
</article>
<PublicDocumentRelations relations={publicRelations(model.relations)} />
</Root>
);
}
function FetchJoinCase({ /**
* Case .
*
* Case . , breadcrumb .
* `publicPath === "/cases/collection-fetch-join-pagination"`
* , Case
* . .
*
* . .
*/
function CaseDocument({
model, model,
embedded, embedded,
resolveEvidenceAsset, resolveEvidenceAsset,
@@ -233,7 +194,7 @@ function FetchJoinCase({
<dd><PlainText text={model.environment} /></dd> <dd><PlainText text={model.environment} /></dd>
</div> </div>
<div> <div>
<dt></dt> <dt> </dt>
<dd><PlainText text={model.reproduction.replace(/^Dataset:\s*/, "")} /></dd> <dd><PlainText text={model.reproduction.replace(/^Dataset:\s*/, "")} /></dd>
</div> </div>
<div> <div>
@@ -474,7 +435,11 @@ function ProjectDecisionDocument({
<header> <header>
<div> <div>
<span>{model.status}</span> <span>{model.status}</span>
{model.decidedOn ? (
<time dateTime={model.decidedOn}>{displayDate(model.decidedOn)}</time> <time dateTime={model.decidedOn}>{displayDate(model.decidedOn)}</time>
) : (
<span> </span>
)}
</div> </div>
<h2>{model.title}</h2> <h2>{model.title}</h2>
<p><PlainText text={model.statement} /></p> <p><PlainText text={model.statement} /></p>
@@ -519,16 +484,8 @@ export function PublicRecordRenderer({
} & RenderDependencies) { } & RenderDependencies) {
switch (model.kind) { switch (model.kind) {
case "CASE": case "CASE":
return model.publicPath === return (
"/cases/collection-fetch-join-pagination" ? ( <CaseDocument
<FetchJoinCase
model={model}
embedded={embedded}
resolveEvidenceAsset={resolveEvidenceAsset}
resolvePublishedLabel={resolvePublishedLabel}
/>
) : (
<GenericCase
model={model} model={model}
embedded={embedded} embedded={embedded}
resolveEvidenceAsset={resolveEvidenceAsset} resolveEvidenceAsset={resolveEvidenceAsset}
@@ -102,7 +102,7 @@ export function CaseFields({
<label className="studio-field"><span> </span><input type="date" value={draft.lastVerifiedOn ?? ""} onChange={(event) => update({ lastVerifiedOn: event.currentTarget.value || null })} /><FieldNotice issues={issues} path="/lastVerifiedOn" /></label> <label className="studio-field"><span> </span><input type="date" value={draft.lastVerifiedOn ?? ""} onChange={(event) => update({ lastVerifiedOn: event.currentTarget.value || null })} /><FieldNotice issues={issues} path="/lastVerifiedOn" /></label>
<label className="studio-field studio-field--wide"><span> Markdown</span><textarea ref={bodyRef} className="studio-markdown-field" value={draft.bodyMarkdown} onChange={(event) => update({ bodyMarkdown: event.currentTarget.value })} /><FieldNotice issues={issues} path="/bodyMarkdown" /></label> <label className="studio-field studio-field--wide"><span> Markdown</span><textarea ref={bodyRef} className="studio-markdown-field" value={draft.bodyMarkdown} onChange={(event) => update({ bodyMarkdown: event.currentTarget.value })} /><FieldNotice issues={issues} path="/bodyMarkdown" /></label>
</div> </div>
<div className="studio-asset-panel" aria-labelledby="studio-asset-panel-title"> <div className="studio-asset-panel" role="group" aria-labelledby="studio-asset-panel-title">
<p className="studio-eyebrow">EVIDENCE</p> <p className="studio-eyebrow">EVIDENCE</p>
<h3 id="studio-asset-panel-title"> Asset </h3> <h3 id="studio-asset-panel-title"> Asset </h3>
<p> evidence . READY Asset만 .</p> <p> evidence . READY Asset만 .</p>
@@ -15,6 +15,7 @@ import { DocumentEditor } from "./document-editor.tsx";
import { GuardedStudioLink } from "./guarded-studio-link.tsx"; import { GuardedStudioLink } from "./guarded-studio-link.tsx";
import { deriveValidationState } from "../../../domain/studio/document-state.ts"; import { deriveValidationState } from "../../../domain/studio/document-state.ts";
import { slugFromName } from "./slug-from-name.ts"; import { slugFromName } from "./slug-from-name.ts";
import { useSaveShortcut } from "./use-save-shortcut.ts";
import { useStudio, useStudioEditorSession } from "../use-studio.ts"; import { useStudio, useStudioEditorSession } from "../use-studio.ts";
type CatalogEntry = components["schemas"]["CatalogEntry"]; type CatalogEntry = components["schemas"]["CatalogEntry"];
@@ -176,6 +177,12 @@ export function DocumentEditorScreen({ documentId }: { documentId: string }) {
} }
}, [begin, editor, setStatus, studio]); }, [begin, editor, setStatus, studio]);
/*
`save` CLEAN·SAVING·CONFLICT
.
*/
useSaveShortcut(save);
/** /**
* . * .
* *
@@ -1,11 +1,9 @@
import { useRef, useState, type KeyboardEvent } from "react";
import type { components } from "../../../contracts/studio/generated.ts"; import type { components } from "../../../contracts/studio/generated.ts";
import type { Asset } from "../../../contracts/studio/contract.ts"; import type { Asset } from "../../../contracts/studio/contract.ts";
import type { DocumentEditorController } from "./document-editor-controller.ts"; import type { DocumentEditorController } from "./document-editor-controller.ts";
import { CASE_FIELD_PATHS, CaseFields } from "./case-fields.tsx"; import { CASE_FIELD_PATHS, CaseFields } from "./case-fields.tsx";
import { COMMON_FIELD_PATHS, CommonDocumentFields } from "./common-document-fields.tsx"; import { COMMON_FIELD_PATHS, CommonDocumentFields } from "./common-document-fields.tsx";
import { DocumentStatusRail } from "./document-status-rail.tsx"; import { DocumentStatusBar } from "./document-status-bar.tsx";
import { InstantPreview } from "./instant-preview.tsx"; import { InstantPreview } from "./instant-preview.tsx";
import { DECISION_FIELD_PATHS, ProjectDecisionFields } from "./project-decision-fields.tsx"; import { DECISION_FIELD_PATHS, ProjectDecisionFields } from "./project-decision-fields.tsx";
import { issuesOutside } from "./field-issues.tsx"; import { issuesOutside } from "./field-issues.tsx";
@@ -28,22 +26,6 @@ export function DocumentEditor({
onAssetsObserved: (assets: readonly Asset[]) => void; onAssetsObserved: (assets: readonly Asset[]) => void;
onAssetUploaded: (asset: Asset) => void; onAssetUploaded: (asset: Asset) => void;
}) { }) {
const [tab, setTab] = useState<"EDIT" | "PREVIEW">("EDIT");
const editTab = useRef<HTMLButtonElement>(null);
const previewTab = useRef<HTMLButtonElement>(null);
const selectTab = (next: "EDIT" | "PREVIEW") => {
setTab(next);
(next === "EDIT" ? editTab : previewTab).current?.focus();
};
const keyDown = (event: KeyboardEvent<HTMLButtonElement>) => {
let next: "EDIT" | "PREVIEW" | null = null;
if (event.key === "ArrowLeft" || event.key === "ArrowRight") next = tab === "EDIT" ? "PREVIEW" : "EDIT";
if (event.key === "Home") next = "EDIT";
if (event.key === "End") next = "PREVIEW";
if (!next) return;
event.preventDefault();
selectTab(next);
};
const topics = catalog.filter(({ type }) => type === "TOPIC"); const topics = catalog.filter(({ type }) => type === "TOPIC");
const projects = catalog.filter(({ type }) => type === "PROJECT"); const projects = catalog.filter(({ type }) => type === "PROJECT");
const relations = catalog.filter(({ type }) => type === "RELATION"); const relations = catalog.filter(({ type }) => type === "RELATION");
@@ -66,18 +48,21 @@ export function DocumentEditor({
: DECISION_FIELD_PATHS; : DECISION_FIELD_PATHS;
const unplaced = issuesOutside(issues, [...COMMON_FIELD_PATHS, ...kindPaths]); const unplaced = issuesOutside(issues, [...COMMON_FIELD_PATHS, ...kindPaths]);
/*
. ,
,
. .
. `aria-labelledby`
landmark "여기는 편집, 저기는 미리보기" .
*/
return ( return (
<div className="studio-editor-page"> <div className="studio-editor-page">
<div className="studio-editor-tabs" role="tablist" aria-label="문서 편집 화면"> <div className="studio-editor-split">
<button id="studio-edit-tab" ref={editTab} type="button" role="tab" aria-selected={tab === "EDIT"} aria-controls="studio-edit-panel" tabIndex={tab === "EDIT" ? 0 : -1} onClick={() => selectTab("EDIT")} onKeyDown={keyDown}></button> <section className="studio-editor-workspace" aria-labelledby="studio-edit-title">
<button id="studio-preview-tab" ref={previewTab} type="button" role="tab" aria-selected={tab === "PREVIEW"} aria-controls="studio-preview-panel" tabIndex={tab === "PREVIEW" ? 0 : -1} onClick={() => selectTab("PREVIEW")} onKeyDown={keyDown}> </button>
</div>
<div className="studio-editor-layout">
<div className="studio-editor-workspace">
<div id="studio-edit-panel" role="tabpanel" aria-labelledby="studio-edit-tab" hidden={tab !== "EDIT"}>
<header className="studio-editor-heading"> <header className="studio-editor-heading">
<p className="studio-eyebrow">{controller.draft.kind} · VERSION {controller.saved.version}</p> <p className="studio-eyebrow">{controller.draft.kind} · VERSION {controller.saved.version}</p>
<h1> </h1> <h1 id="studio-edit-title"> </h1>
<p>{controller.draft.title || "제목 없는 작업본"}</p> <p>{controller.draft.title || "제목 없는 작업본"}</p>
</header> </header>
<CommonDocumentFields draft={controller.draft} topics={topics} projects={projects} relations={relations} issues={issues} onUpdate={controller.update} /> <CommonDocumentFields draft={controller.draft} topics={topics} projects={projects} relations={relations} issues={issues} onUpdate={controller.update} />
@@ -88,13 +73,22 @@ export function DocumentEditor({
: controller.draft.kind === "QUESTION" : controller.draft.kind === "QUESTION"
? <QuestionFields draft={controller.draft} evidence={evidence} issues={issues} onChange={controller.replace} /> ? <QuestionFields draft={controller.draft} evidence={evidence} issues={issues} onChange={controller.replace} />
: <ProjectDecisionFields draft={controller.draft} issues={issues} onChange={controller.replace} />} : <ProjectDecisionFields draft={controller.draft} issues={issues} onChange={controller.replace} />}
</div> </section>
<div id="studio-preview-panel" role="tabpanel" aria-labelledby="studio-preview-tab" hidden={tab !== "PREVIEW"}> {/*
, .
.
*/}
<section className="studio-editor-preview" aria-labelledby="studio-preview-title">
<header className="studio-editor-preview__heading">
<p className="studio-eyebrow">LIVE</p>
<h2 id="studio-preview-title"> </h2>
</header>
<div className="studio-editor-preview__body">
<InstantPreview draft={controller.draft} catalog={catalog} assets={assets} /> <InstantPreview draft={controller.draft} catalog={catalog} assets={assets} />
</div> </div>
</section>
</div> </div>
<DocumentStatusRail controller={controller} unplacedIssues={unplaced} /> <DocumentStatusBar controller={controller} unplacedIssues={unplaced} />
</div>
</div> </div>
); );
} }
@@ -0,0 +1,91 @@
import type { DocumentEditorController } from "./document-editor-controller.ts";
import type { FieldIssue } from "./field-issues.tsx";
const labels = {
CLEAN: "저장됨",
DIRTY: "저장되지 않음",
SAVING: "저장 중…",
CONFLICT: "저장 충돌",
} as const;
const kindLabels = {
CASE: "CASE",
REFERENCE: "REFERENCE",
QUESTION: "QUESTION",
PROJECT_DECISION: "Decision",
} as const;
/**
* · .
*
* rail . ,
* rail .
* ·
* .
*
* `aside` .
* , .
*/
export function DocumentStatusBar({
controller,
unplacedIssues,
}: {
controller: DocumentEditorController;
/**
* . ,
* .
*/
unplacedIssues: readonly FieldIssue[];
}) {
const busy = controller.status === "SAVING" || controller.publishing;
return (
<aside className="studio-document-status-bar" aria-labelledby="studio-document-status-title">
<h2 id="studio-document-status-title" className="studio-visually-hidden"> </h2>
<p className={`studio-editor-status studio-editor-status--${controller.status.toLowerCase()}`} role="status" aria-label="편집 상태">{labels[controller.status]}</p>
<dl><div><dt> </dt><dd>{controller.saved.version}</dd></div><div><dt></dt><dd>{kindLabels[controller.draft.kind]}</dd></div></dl>
{/*
.
, .
.
*/}
{controller.status === "CONFLICT" ? (
<p className="studio-editor-conflict" role="alert"> . .</p>
) : controller.saveError ? (
<p className="studio-editor-conflict" role="alert">{controller.saveError}</p>
) : controller.publishError ? (
<p className="studio-editor-conflict" role="alert">{controller.publishError}</p>
) : (
<p className="studio-document-status-bar__note"> . Ctrl+S . .</p>
)}
<div className="studio-document-status-bar__actions">
<button type="button" title="Ctrl+S" onClick={() => { void controller.save(); }} disabled={busy || controller.status === "CLEAN" || controller.status === "CONFLICT"}>{controller.status === "SAVING" ? "저장 중…" : "저장"}</button>
{/*
.
id id,
(`PublishStudioDocumentUseCase`).
. ,
, . .
.
*/}
<button
className="studio-primary-button"
type="button"
onClick={() => { void controller.publish(); }}
disabled={busy || controller.status === "CONFLICT"}
>
{controller.publishing ? "게시 중…" : "게시"}
</button>
</div>
{unplacedIssues.length ? (
<ul className="studio-editor-unplaced-issues" aria-label="칸에 붙지 못한 지적">
{unplacedIssues.map((issue) => (
<li key={`${issue.code}:${issue.path}`} data-severity={issue.severity}>
{issue.message} <code>{issue.path}</code>
</li>
))}
</ul>
) : null}
</aside>
);
}
@@ -1,79 +0,0 @@
import type { DocumentEditorController } from "./document-editor-controller.ts";
import type { FieldIssue } from "./field-issues.tsx";
const labels = {
CLEAN: "저장됨",
DIRTY: "저장되지 않음",
SAVING: "저장 중…",
CONFLICT: "저장 충돌",
} as const;
const kindLabels = {
CASE: "CASE",
REFERENCE: "REFERENCE",
QUESTION: "QUESTION",
PROJECT_DECISION: "Decision",
} as const;
export function DocumentStatusRail({
controller,
unplacedIssues,
}: {
controller: DocumentEditorController;
/**
* . ,
* .
*/
unplacedIssues: readonly FieldIssue[];
}) {
const busy = controller.status === "SAVING" || controller.publishing;
return (
<aside className="studio-document-status-rail" aria-labelledby="studio-document-status-title">
<p className="studio-eyebrow">WORKING COPY</p>
<h2 id="studio-document-status-title"> </h2>
<p className={`studio-editor-status studio-editor-status--${controller.status.toLowerCase()}`} role="status" aria-label="편집 상태">{labels[controller.status]}</p>
<dl><div><dt> </dt><dd>{controller.saved.version}</dd></div><div><dt></dt><dd>{kindLabels[controller.draft.kind]}</dd></div></dl>
<button type="button" onClick={() => { void controller.save(); }} disabled={busy || controller.status === "CLEAN" || controller.status === "CONFLICT"}>{controller.status === "SAVING" ? "저장 중…" : "저장"}</button>
{/*
.
id id,
(`PublishStudioDocumentUseCase`).
. ,
, . .
.
*/}
<button
className="studio-primary-button"
type="button"
onClick={() => { void controller.publish(); }}
disabled={busy || controller.status === "CONFLICT"}
>
{controller.publishing ? "게시 중…" : "게시"}
</button>
{/*
.
, .
.
*/}
{controller.status === "CONFLICT" ? (
<p className="studio-editor-conflict" role="alert"> . .</p>
) : controller.saveError ? (
<p className="studio-editor-conflict" role="alert">{controller.saveError}</p>
) : controller.publishError ? (
<p className="studio-editor-conflict" role="alert">{controller.publishError}</p>
) : (
<p> . .</p>
)}
{unplacedIssues.length ? (
<ul className="studio-editor-unplaced-issues" aria-label="칸에 붙지 못한 지적">
{unplacedIssues.map((issue) => (
<li key={`${issue.code}:${issue.path}`} data-severity={issue.severity}>
{issue.message} <code>{issue.path}</code>
</li>
))}
</ul>
) : null}
</aside>
);
}
@@ -0,0 +1,200 @@
import { useCallback, useEffect, useState, type FormEvent } from "react";
import type { components } from "../../../contracts/studio/generated.ts";
import type {
HomeFocusResponse,
ProjectIndexItem,
} from "../../../contracts/management/contract.ts";
import { managementFailureMessage } from "../../../application/ports/management-gateway-error.ts";
import { GuardedStudioLink } from "./guarded-studio-link.tsx";
import { useStudio } from "../use-studio.ts";
type CatalogEntry = components["schemas"]["CatalogEntry"];
/**
* "지금 집중하는 것" .
*
* <p> ( · · )
* . `home_focus_config` ,
* .
*
* <p> . Studio catalog `RELATION`
* "연결 가능한 대상" ,
* .
*
* <p> .
* . .
*/
export function HomeFocusEditor() {
const { gateway, managementGateway, setRequestAnnouncement } = useStudio();
const [focus, setFocus] = useState<HomeFocusResponse | null>(null);
const [projects, setProjects] = useState<ProjectIndexItem[]>([]);
const [relations, setRelations] = useState<CatalogEntry[]>([]);
const [projectId, setProjectId] = useState("");
const [questionId, setQuestionId] = useState("");
const [decisionId, setDecisionId] = useState("");
const [pending, setPending] = useState(false);
const [error, setError] = useState("");
const [generation, setGeneration] = useState(0);
const reload = useCallback(() => setGeneration((value) => value + 1), []);
useEffect(() => {
let cancelled = false;
/*
try/catch . `Promise.all([gateway.foo()])` `foo`
(: )
rejection handler .
. .
*/
void (async () => {
try {
const [current, projectPage, catalog] = await Promise.all([
managementGateway.getHomeFocus(),
managementGateway.listProjects(0, 50),
gateway.getCatalog({ type: "RELATION", limit: 100 }),
]);
if (cancelled) return;
setFocus(current);
setProjects(projectPage.items ?? []);
setRelations(catalog.items ?? []);
setProjectId(current.currentProjectId ?? "");
setQuestionId(current.openQuestionId ?? "");
setDecisionId(current.recentDecisionId ?? "");
setError("");
} catch {
if (!cancelled) setError("홈 설정을 불러오지 못했습니다.");
}
})();
return () => {
cancelled = true;
};
}, [gateway, managementGateway, generation]);
const questions = relations.filter((entry) => entry.kind === "QUESTION");
const decisions = relations.filter((entry) => entry.kind === "PROJECT_DECISION");
const save = async (event: FormEvent) => {
event.preventDefault();
if (pending || !focus) return;
setPending(true);
setError("");
try {
const saved = await managementGateway.updateHomeFocus({
expectedVersion: focus.version,
// 빈 문자열은 "고르지 않음" 이다. 계약은 uuid 만 받으므로 보내지 않는다.
currentProjectId: projectId || undefined,
openQuestionId: questionId || undefined,
recentDecisionId: decisionId || undefined,
});
setFocus(saved);
setRequestAnnouncement("홈에 표시할 항목을 저장했습니다.");
reload();
} catch (failure) {
setError(managementFailureMessage(failure, "홈 설정을 저장하지 못했습니다."));
} finally {
setPending(false);
}
};
const chosenProject = projects.find((project) => project.id === projectId);
const projectHidden = chosenProject && chosenProject.targetVisibility === "PRIVATE";
const nothingChosen = !projectId && !questionId && !decisionId;
return (
<section className="studio-editor-section" aria-labelledby="studio-home-focus-title">
<div className="studio-editor-section-heading">
<p className="studio-eyebrow">PUBLIC HOME</p>
<h2 id="studio-home-focus-title"> </h2>
<p>
. .
</p>
</div>
{error ? (
<p className="studio-screen-error" role="alert">
{error}
</p>
) : null}
{!focus && !error ? (
<p className="studio-loading" role="status">
.
</p>
) : null}
{focus ? (
<form onSubmit={save}>
<div className="studio-field-grid">
<label className="studio-field studio-field--wide">
<span> ()</span>
<select value={projectId} onChange={(event) => setProjectId(event.currentTarget.value)}>
<option value=""> </option>
{projects.map((project) => (
<option key={project.id} value={project.id}>
{project.name}
{project.targetVisibility === "PRIVATE" ? " (비공개)" : ""}
</option>
))}
</select>
{projectHidden ? (
<small>
. · .
</small>
) : (
<small>· · .</small>
)}
</label>
<label className="studio-field">
<span> </span>
<select
value={questionId}
onChange={(event) => setQuestionId(event.currentTarget.value)}
disabled={questions.length === 0}
>
<option value=""> </option>
{questions.map((entry) => (
<option key={entry.id} value={entry.id}>
{entry.label}
</option>
))}
</select>
{questions.length === 0 ? <small> Question .</small> : null}
</label>
<label className="studio-field">
<span> </span>
<select
value={decisionId}
onChange={(event) => setDecisionId(event.currentTarget.value)}
disabled={decisions.length === 0}
>
<option value=""> </option>
{decisions.map((entry) => (
<option key={entry.id} value={entry.id}>
{entry.label}
</option>
))}
</select>
{decisions.length === 0 ? <small> Decision .</small> : null}
</label>
</div>
<div className="studio-editor-footer">
<button className="studio-secondary-button" type="submit" disabled={pending}>
{pending ? "저장하는 중" : "홈 설정 저장"}
</button>
<p className="studio-field-note">
{nothingChosen
? "지금은 아무것도 고르지 않아 홈에 이 영역이 나타나지 않습니다."
: "저장하면 공개 홈에 바로 반영됩니다."}
{" "}
<GuardedStudioLink href="/studio/taxonomy">·</GuardedStudioLink>
.
</p>
</div>
</form>
) : null}
</section>
);
}
@@ -1,5 +1,5 @@
import type { components } from "../../../contracts/studio/generated.ts"; import type { components } from "../../../contracts/studio/generated.ts";
import { createLocalId } from "../../../domain/studio/local-id.ts"; import { createNewItemId } from "../../../domain/studio/local-id.ts";
type OrderedText = components["schemas"]["OrderedText"]; type OrderedText = components["schemas"]["OrderedText"];
@@ -25,7 +25,7 @@ export function OrderedTextList({ label, fieldId, items, onChange }: { label: st
<div className="studio-item-actions"><button type="button" onClick={() => move(index, -1)} disabled={index === 0}></button><button type="button" onClick={() => move(index, 1)} disabled={index === items.length - 1}></button><button type="button" onClick={() => onChange(ordered(items.filter((_, candidateIndex) => candidateIndex !== index)))}></button></div> <div className="studio-item-actions"><button type="button" onClick={() => move(index, -1)} disabled={index === 0}></button><button type="button" onClick={() => move(index, 1)} disabled={index === items.length - 1}></button><button type="button" onClick={() => onChange(ordered(items.filter((_, candidateIndex) => candidateIndex !== index)))}></button></div>
</div> </div>
))} ))}
<button className="studio-add-item" type="button" disabled={items.length >= 50} onClick={() => { if (items.length < 50) onChange(ordered([...items, { id: createLocalId("item"), text: "", order: items.length }])); }}>{label} </button> <button className="studio-add-item" type="button" disabled={items.length >= 50} onClick={() => { if (items.length < 50) onChange(ordered([...items, { id: createNewItemId(), text: "", order: items.length }])); }}>{label} </button>
</fieldset> </fieldset>
); );
} }
@@ -0,0 +1,458 @@
import { useCallback, useEffect, useState, type FormEvent } from "react";
import type { components } from "../../../contracts/studio/generated.ts";
import type {
ProjectActivityResponse,
ProjectEditResponse,
TopicEdit,
} from "../../../contracts/management/contract.ts";
import { managementFailureMessage } from "../../../application/ports/management-gateway-error.ts";
import { useRouteInput } from "../../../../../presentation/routes/route-input.tsx";
import { GuardedStudioLink } from "./guarded-studio-link.tsx";
import { slugFromName } from "./slug-from-name.ts";
import { useSaveShortcut } from "./use-save-shortcut.ts";
import { useStudio } from "../use-studio.ts";
type CatalogEntry = components["schemas"]["CatalogEntry"];
/** 경로 파라미터는 `unknown` 으로 들어온다 — 라우터가 코덱을 통과시킨 값이라도 타입은 좁혀 써야 한다. */
function routeId(value: unknown): string {
return typeof value === "string" ? value : "";
}
/** `project_phase_check` 와 같은 집합이다 — 화면이 더 많은 값을 보여 주면 저장이 제약에서 터진다. */
const PHASES = [
{ value: "RESEARCH", label: "조사" },
{ value: "DESIGN", label: "설계" },
{ value: "IMPLEMENTATION", label: "구현" },
{ value: "VERIFICATION", label: "검증" },
{ value: "MAINTENANCE", label: "유지" },
{ value: "PAUSED", label: "중단" },
{ value: "COMPLETED", label: "완료" },
] as const;
/** `project_activity_activity_type_check` 와 같은 집합이다. */
const ACTIVITY_TYPES = [
{ value: "MILESTONE_REACHED", label: "이정표 도달" },
{ value: "PHASE_CHANGED", label: "단계 전환" },
{ value: "QUESTION_OPENED", label: "질문 열림" },
{ value: "QUESTION_RESOLVED", label: "질문 해결" },
{ value: "DECISION_ACCEPTED", label: "결정 수락" },
{ value: "CASE_PUBLISHED", label: "Case 게시" },
{ value: "REFERENCE_PUBLISHED", label: "Reference 게시" },
{ value: "PROJECT_PAUSED", label: "프로젝트 중단" },
{ value: "PROJECT_RESUMED", label: "프로젝트 재개" },
] as const;
const activityTypeLabel = (value: string) =>
ACTIVITY_TYPES.find((type) => type.value === value)?.label ?? value;
/** `<input type="datetime-local">` 은 초와 시간대가 없는 지역 시각을 쓴다. */
function toLocalInput(isoTimestamp: string): string {
const value = new Date(isoTimestamp);
if (Number.isNaN(value.getTime())) return "";
const pad = (part: number) => String(part).padStart(2, "0");
return `${value.getFullYear()}-${pad(value.getMonth() + 1)}-${pad(value.getDate())}T${pad(
value.getHours(),
)}:${pad(value.getMinutes())}`;
}
function fromLocalInput(value: string): string {
const parsed = new Date(value);
return Number.isNaN(parsed.getTime()) ? new Date().toISOString() : parsed.toISOString();
}
type Draft = {
name: string;
slug: string;
oneLinePurpose: string;
purposeMarkdown: string;
boundaryMarkdown: string;
systemOverviewMarkdown: string;
phase: string;
currentObjective: string;
nextStep: string;
technologyLabels: string;
topicIds: readonly string[];
};
function toDraft(project: ProjectEditResponse): Draft {
return {
name: project.name,
slug: project.slug ?? "",
oneLinePurpose: project.oneLinePurpose ?? "",
purposeMarkdown: project.purposeMarkdown ?? "",
boundaryMarkdown: project.boundaryMarkdown ?? "",
systemOverviewMarkdown: project.systemOverviewMarkdown ?? "",
phase: project.phase,
currentObjective: project.currentObjective ?? "",
nextStep: project.nextStep ?? "",
technologyLabels: (project.technologyLabels ?? []).join(", "),
topicIds: project.topicIds ?? [],
};
}
/**
* .
*
* <p> slug . "현재 목표"·"
* "·" " `updateProject`
* .
*
* <p> . ,
* .
*
* <p> CSS `studio-editor-section`·`studio-field-grid`
* Studio ·· .
*/
export function ProjectEditor() {
const { params } = useRouteInput<"TECH_LOG_STUDIO_PROJECT_EDIT">();
const projectId = routeId(params.id);
const { gateway, managementGateway, setRequestAnnouncement } = useStudio();
const [project, setProject] = useState<ProjectEditResponse | null>(null);
const [topics, setTopics] = useState<TopicEdit[]>([]);
const [records, setRecords] = useState<CatalogEntry[]>([]);
const [activities, setActivities] = useState<ProjectActivityResponse[]>([]);
const [draft, setDraft] = useState<Draft | null>(null);
const [pending, setPending] = useState(false);
const [error, setError] = useState("");
const [generation, setGeneration] = useState(0);
const reload = useCallback(() => setGeneration((value) => value + 1), []);
useEffect(() => {
let cancelled = false;
// 동기적으로 던지는 호출도 잡는다 — `home-focus-editor` 와 같은 이유다.
void (async () => {
try {
const [loaded, activityList, topicList, catalog] = await Promise.all([
managementGateway.getProject(projectId),
managementGateway.listProjectActivities(projectId),
managementGateway.listTopics(),
gateway.getCatalog({ type: "RELATION", limit: 100 }),
]);
if (cancelled) return;
setProject(loaded);
setDraft(toDraft(loaded));
setActivities(activityList);
setTopics(topicList.filter((topic) => topic.status !== "ARCHIVED"));
setRecords(catalog.items ?? []);
setError("");
} catch {
if (!cancelled) setError("프로젝트를 불러오지 못했습니다.");
}
})();
return () => {
cancelled = true;
};
}, [gateway, managementGateway, projectId, generation]);
const update = (patch: Partial<Draft>) =>
setDraft((current) => (current ? { ...current, ...patch } : current));
const toggleTopic = (topicId: string) =>
setDraft((current) => {
if (!current) return current;
const chosen = current.topicIds.includes(topicId)
? current.topicIds.filter((id) => id !== topicId)
: [...current.topicIds, topicId];
return { ...current, topicIds: chosen };
});
const save = async (event: FormEvent) => {
event.preventDefault();
if (pending || !project || !draft) return;
setPending(true);
setError("");
try {
/*
slug .
, "slug 가 없다" .
*/
const saved = await managementGateway.updateProject(project.id, {
expectedVersion: project.version,
name: draft.name.trim(),
slug: draft.slug.trim() || slugFromName(draft.name),
oneLinePurpose: draft.oneLinePurpose,
purposeMarkdown: draft.purposeMarkdown,
boundaryMarkdown: draft.boundaryMarkdown,
systemOverviewMarkdown: draft.systemOverviewMarkdown,
phase: draft.phase,
currentObjective: draft.currentObjective,
nextStep: draft.nextStep,
technologyLabels: draft.technologyLabels
.split(",")
.map((label) => label.trim())
.filter(Boolean),
topicIds: [...draft.topicIds],
targetVisibility:
project.targetVisibility === "PUBLIC" || project.targetVisibility === "UNLISTED"
? project.targetVisibility
: "PRIVATE",
featuredOrder: project.featuredOrder,
});
/*
.
.
*/
if (saved.targetVisibility !== "PRIVATE") {
await managementGateway.publishProject(
saved.id,
saved.version,
saved.targetVisibility === "UNLISTED" ? "UNLISTED" : "PUBLIC",
);
}
setRequestAnnouncement(`프로젝트를 버전 ${saved.version}으로 저장했습니다.`);
reload();
} catch (failure) {
setError(managementFailureMessage(failure, "프로젝트를 저장하지 못했습니다."));
} finally {
setPending(false);
}
};
// 폼 제출과 같은 일을 하므로 submit 이벤트를 만들어 보낸다 — 검증·기본값 처리가 한 곳에 남는다.
useSaveShortcut(
() => void save({ preventDefault: () => {} } as FormEvent),
Boolean(project && draft) && !pending,
);
const published = project ? project.targetVisibility !== "PRIVATE" : false;
return (
<div className="studio-page studio-editor-page">
<header className="studio-page-top">
<div className="studio-page-heading">
<p className="studio-eyebrow">
PROJECT · VERSION {project?.version ?? "—"}
</p>
<h1>{project?.name ?? "프로젝트"}</h1>
<p>
&ldquo; &rdquo;,
.
</p>
</div>
<GuardedStudioLink className="studio-primary-action" href="/studio/taxonomy">
·
</GuardedStudioLink>
</header>
{error ? (
<p className="studio-screen-error" role="alert">
{error}
</p>
) : null}
{!project && !error ? (
<p className="studio-loading" role="status">
.
</p>
) : null}
{project && draft ? (
<>
<form onSubmit={save}>
<section
className="studio-editor-section"
aria-labelledby="studio-project-basics-title"
>
<div className="studio-editor-section-heading">
<p className="studio-eyebrow">PROJECT</p>
<h2 id="studio-project-basics-title"> </h2>
<p> .</p>
</div>
<div className="studio-field-grid">
<label className="studio-field studio-field--wide">
<span></span>
<input
value={draft.name}
maxLength={120}
onChange={(event) => update({ name: event.currentTarget.value })}
/>
</label>
<label className="studio-field">
<span>slug</span>
<input
value={draft.slug}
maxLength={100}
placeholder="비우면 이름에서 만듭니다"
onChange={(event) => update({ slug: event.currentTarget.value })}
/>
</label>
<label className="studio-field">
<span></span>
<select
value={draft.phase}
onChange={(event) => update({ phase: event.currentTarget.value })}
>
{PHASES.map((phase) => (
<option key={phase.value} value={phase.value}>
{phase.label}
</option>
))}
</select>
</label>
<label className="studio-field studio-field--wide">
<span> </span>
<input
value={draft.oneLinePurpose}
maxLength={200}
onChange={(event) => update({ oneLinePurpose: event.currentTarget.value })}
/>
</label>
<label className="studio-field">
<span> </span>
<input
value={draft.currentObjective}
onChange={(event) => update({ currentObjective: event.currentTarget.value })}
/>
</label>
<label className="studio-field">
<span> </span>
<input
value={draft.nextStep}
onChange={(event) => update({ nextStep: event.currentTarget.value })}
/>
</label>
<label className="studio-field studio-field--wide">
<span> </span>
<input
value={draft.technologyLabels}
placeholder="쉼표로 구분합니다"
onChange={(event) => update({ technologyLabels: event.currentTarget.value })}
/>
</label>
</div>
</section>
<section
className="studio-editor-section"
aria-labelledby="studio-project-topics-title"
>
<div className="studio-editor-section-heading">
<p className="studio-eyebrow">TOPICS</p>
<h2 id="studio-project-topics-title"> </h2>
<p> &ldquo; &rdquo; .</p>
</div>
{topics.length === 0 ? (
<p className="studio-empty-inline">
. · .
</p>
) : (
<ul className="studio-choice-list">
{topics.map((topic) => (
<li key={topic.id}>
<label className="studio-choice">
<input
type="checkbox"
checked={draft.topicIds.includes(topic.id ?? "")}
onChange={() => toggleTopic(topic.id ?? "")}
/>
<span>{topic.name}</span>
<small>{topic.slug}</small>
</label>
</li>
))}
</ul>
)}
</section>
<section
className="studio-editor-section"
aria-labelledby="studio-project-narrative-title"
>
<div className="studio-editor-section-heading">
<p className="studio-eyebrow">NARRATIVE</p>
<h2 id="studio-project-narrative-title"></h2>
<p>
&ldquo; &rdquo; .
</p>
</div>
{/*
.
.
, .
( , ).
.
*/}
<div className="studio-field-grid">
<label className="studio-field studio-field--wide">
<span></span>
<textarea
className="studio-markdown"
value={draft.purposeMarkdown}
onChange={(event) => update({ purposeMarkdown: event.currentTarget.value })}
/>
<small> .</small>
</label>
</div>
</section>
<div className="studio-editor-footer">
<button className="studio-primary-button" type="submit" title="Ctrl+S" disabled={pending}>
{pending ? "저장하는 중" : "저장"}
</button>
<p className="studio-field-note">
{published
? "공개된 프로젝트입니다. 저장하면 공개 화면도 함께 갱신됩니다."
: "아직 비공개입니다. 주제·프로젝트 화면에서 게시해야 공개 화면에 나옵니다."}
</p>
</div>
</form>
<section
className="studio-editor-section"
aria-labelledby="studio-project-activity-title"
>
<div className="studio-editor-section-heading">
<p className="studio-eyebrow">ACTIVITY</p>
<h2 id="studio-project-activity-title"></h2>
<p>
. .
</p>
</div>
{activities.length === 0 ? (
<p className="studio-empty-inline">
.
</p>
) : (
<div className="studio-document-list">
{activities.map((activity) => (
<article className="studio-document-row" key={activity.id}>
<p className="studio-row-label">{activityTypeLabel(activity.activityType)}</p>
<div className="studio-document-title">
<h2>{activity.title}</h2>
<p>
{activity.visibility === "PUBLIC"
? "공개 타임라인에 나옵니다"
: "비공개 — 타임라인에 나오지 않습니다"}
</p>
</div>
<dl>
<div>
<dt></dt>
<dd>
<time dateTime={activity.occurredAt}>
{new Date(activity.occurredAt).toLocaleDateString("ko-KR", {
timeZone: "Asia/Seoul",
})}
</time>
</dd>
</div>
<div>
<dt></dt>
<dd>{activity.origin === "AUTO" ? "게시가 남김" : "손으로 적음"}</dd>
</div>
</dl>
</article>
))}
</div>
)}
</section>
</>
) : null}
</div>
);
}
@@ -1,5 +1,5 @@
import type { components } from "../../../contracts/studio/generated.ts"; import type { components } from "../../../contracts/studio/generated.ts";
import { createLocalId } from "../../../domain/studio/local-id.ts"; import { createNewItemId } from "../../../domain/studio/local-id.ts";
import { FieldNotice, type FieldIssue } from "./field-issues.tsx"; import { FieldNotice, type FieldIssue } from "./field-issues.tsx";
import { OrderedTextList } from "./ordered-text-list.tsx"; import { OrderedTextList } from "./ordered-text-list.tsx";
@@ -56,7 +56,7 @@ export function QuestionFields({ draft, evidence, issues, onChange }: { draft: Q
<label><span> {index + 1} </span><textarea value={option.description} onChange={(event) => update({ options: orderedOptions(draft.options.map((candidate, candidateIndex) => candidateIndex === index ? { ...candidate, description: event.currentTarget.value } : candidate)) })} /></label> <label><span> {index + 1} </span><textarea value={option.description} onChange={(event) => update({ options: orderedOptions(draft.options.map((candidate, candidateIndex) => candidateIndex === index ? { ...candidate, description: event.currentTarget.value } : candidate)) })} /></label>
<div className="studio-item-actions"><button type="button" onClick={() => moveOption(index, -1)} disabled={index === 0}></button><button type="button" onClick={() => moveOption(index, 1)} disabled={index === draft.options.length - 1}></button><button type="button" onClick={() => update({ options: orderedOptions(draft.options.filter((_, candidateIndex) => candidateIndex !== index)) })}></button></div> <div className="studio-item-actions"><button type="button" onClick={() => moveOption(index, -1)} disabled={index === 0}></button><button type="button" onClick={() => moveOption(index, 1)} disabled={index === draft.options.length - 1}></button><button type="button" onClick={() => update({ options: orderedOptions(draft.options.filter((_, candidateIndex) => candidateIndex !== index)) })}></button></div>
</div>)} </div>)}
<button className="studio-add-item" type="button" disabled={draft.options.length >= 50} onClick={() => { if (draft.options.length < 50) update({ options: orderedOptions([...draft.options, { id: createLocalId("option"), title: "", description: "", order: draft.options.length }]) }); }}> </button> <button className="studio-add-item" type="button" disabled={draft.options.length >= 50} onClick={() => { if (draft.options.length < 50) update({ options: orderedOptions([...draft.options, { id: createNewItemId(), title: "", description: "", order: draft.options.length }]) }); }}> </button>
</fieldset> </fieldset>
<label className="studio-field studio-field--wide"><span> </span><textarea value={draft.nextValidation} onChange={(event) => update({ nextValidation: event.currentTarget.value })} /><FieldNotice issues={issues} path="/nextValidation" /></label> <label className="studio-field studio-field--wide"><span> </span><textarea value={draft.nextValidation} onChange={(event) => update({ nextValidation: event.currentTarget.value })} /><FieldNotice issues={issues} path="/nextValidation" /></label>
{draft.questionStatus === "RESOLVED" && draft.resolution ? <fieldset className="studio-resolution-fields"><legend> </legend> {draft.questionStatus === "RESOLVED" && draft.resolution ? <fieldset className="studio-resolution-fields"><legend> </legend>
@@ -1,5 +1,5 @@
import type { components } from "../../../contracts/studio/generated.ts"; import type { components } from "../../../contracts/studio/generated.ts";
import { createLocalId } from "../../../domain/studio/local-id.ts"; import { createNewItemId } from "../../../domain/studio/local-id.ts";
import { FieldNotice, type FieldIssue } from "./field-issues.tsx"; import { FieldNotice, type FieldIssue } from "./field-issues.tsx";
import { OrderedTextList } from "./ordered-text-list.tsx"; import { OrderedTextList } from "./ordered-text-list.tsx";
@@ -41,7 +41,7 @@ export function ReferenceFields({ draft, issues, onChange }: { draft: ReferenceI
<label><span> {index + 1} </span><textarea value={rule.body} onChange={(event) => update({ rules: orderedRules(draft.rules.map((candidate, candidateIndex) => candidateIndex === index ? { ...candidate, body: event.currentTarget.value } : candidate)) })} /></label> <label><span> {index + 1} </span><textarea value={rule.body} onChange={(event) => update({ rules: orderedRules(draft.rules.map((candidate, candidateIndex) => candidateIndex === index ? { ...candidate, body: event.currentTarget.value } : candidate)) })} /></label>
<div className="studio-item-actions"><button type="button" onClick={() => moveRule(index, -1)} disabled={index === 0}></button><button type="button" onClick={() => moveRule(index, 1)} disabled={index === draft.rules.length - 1}></button><button type="button" onClick={() => update({ rules: orderedRules(draft.rules.filter((_, candidateIndex) => candidateIndex !== index)) })}></button></div> <div className="studio-item-actions"><button type="button" onClick={() => moveRule(index, -1)} disabled={index === 0}></button><button type="button" onClick={() => moveRule(index, 1)} disabled={index === draft.rules.length - 1}></button><button type="button" onClick={() => update({ rules: orderedRules(draft.rules.filter((_, candidateIndex) => candidateIndex !== index)) })}></button></div>
</div>)} </div>)}
<button className="studio-add-item" type="button" disabled={draft.rules.length >= 50} onClick={() => { if (draft.rules.length < 50) update({ rules: orderedRules([...draft.rules, { id: createLocalId("rule"), title: "", body: "", order: draft.rules.length }]) }); }}> </button> <button className="studio-add-item" type="button" disabled={draft.rules.length >= 50} onClick={() => { if (draft.rules.length < 50) update({ rules: orderedRules([...draft.rules, { id: createNewItemId(), title: "", body: "", order: draft.rules.length }]) }); }}> </button>
</fieldset> </fieldset>
<OrderedTextList label="적용 조건" items={draft.applyWhen} onChange={(applyWhen) => update({ applyWhen })} /> <OrderedTextList label="적용 조건" items={draft.applyWhen} onChange={(applyWhen) => update({ applyWhen })} />
<FieldNotice issues={issues} path="/applyWhen" /> <FieldNotice issues={issues} path="/applyWhen" />
@@ -1,5 +1,5 @@
import type { components } from "../../../contracts/studio/generated.ts"; import type { components } from "../../../contracts/studio/generated.ts";
import { createLocalId } from "../../../domain/studio/local-id.ts"; import { createNewItemId } from "../../../domain/studio/local-id.ts";
type CatalogEntry = components["schemas"]["CatalogEntry"]; type CatalogEntry = components["schemas"]["CatalogEntry"];
type RelationInput = components["schemas"]["RelationInput"]; type RelationInput = components["schemas"]["RelationInput"];
@@ -29,7 +29,7 @@ export function RelationEditor({ relations, catalog, evidence = false, onChange
<div className="studio-item-actions"><button type="button" onClick={() => move(index, -1)} disabled={index === 0}></button><button type="button" onClick={() => move(index, 1)} disabled={index === relations.length - 1}></button><button type="button" onClick={() => onChange(ordered(relations.filter((_, candidateIndex) => candidateIndex !== index)))}></button></div> <div className="studio-item-actions"><button type="button" onClick={() => move(index, -1)} disabled={index === 0}></button><button type="button" onClick={() => move(index, 1)} disabled={index === relations.length - 1}></button><button type="button" onClick={() => onChange(ordered(relations.filter((_, candidateIndex) => candidateIndex !== index)))}></button></div>
</div> </div>
))} ))}
<button className="studio-add-item" type="button" disabled={relations.length >= 20} onClick={() => { if (relations.length < 20) onChange(ordered([...relations, { id: createLocalId("relation"), targetId: null, reason: "", order: relations.length }])); }}>{noun} </button> <button className="studio-add-item" type="button" disabled={relations.length >= 20} onClick={() => { if (relations.length < 20) onChange(ordered([...relations, { id: createNewItemId(), targetId: null, reason: "", order: relations.length }])); }}>{noun} </button>
</fieldset> </fieldset>
); );
} }
@@ -0,0 +1,342 @@
import { useCallback, useEffect, useState } from "react";
import type {
ReleaseEditResponse,
ReleaseUpdateRequest,
} from "../../../contracts/management/contract.ts";
import { managementFailureMessage } from "../../../application/ports/management-gateway-error.ts";
import { useRouteInput } from "../../../../../presentation/routes/route-input.tsx";
import { GuardedStudioLink } from "./guarded-studio-link.tsx";
import { useSaveShortcut } from "./use-save-shortcut.ts";
import { useStudio } from "../use-studio.ts";
/**
* .
*
* <p> <em></em> .
* , . ·
* .
*
* <p> .
* "검증을 안 썼다" .
*/
/** 백엔드 {@code UpdateReleaseUseCase.CHANGE_TYPES} 와 같은 집합이다. */
const CHANGE_TYPES = [
{ value: "FEATURE", label: "기능" },
{ value: "FIX", label: "수정" },
{ value: "REFACTOR", label: "구조" },
{ value: "DOCS", label: "문서" },
{ value: "INFRA", label: "인프라" },
{ value: "BREAKING", label: "호환 깨짐" },
] as const;
const SECTIONS = [
{ key: "reasonMarkdown", label: "왜 바꿨나" },
{ key: "changesMarkdown", label: "무엇을 바꿨나" },
{ key: "userImpactMarkdown", label: "사용자에게 달라지는 것" },
{ key: "implementationImpactMarkdown", label: "구현에 남는 것" },
{ key: "verificationMarkdown", label: "어떻게 검증했나" },
{ key: "knownLimitationsMarkdown", label: "아직 못 한 것" },
] as const;
type Draft = Readonly<{
expectedVersion: number;
versionLabel: string;
title: string;
summary: string;
releasedOn: string;
changeTypes: readonly string[];
reasonMarkdown: string;
changesMarkdown: string;
userImpactMarkdown: string;
implementationImpactMarkdown: string;
verificationMarkdown: string;
knownLimitationsMarkdown: string;
}>;
function toDraft(release: ReleaseEditResponse): Draft {
return {
expectedVersion: release.version,
versionLabel: release.versionLabel,
title: release.title,
summary: release.summary ?? "",
releasedOn: release.releasedOn ?? "",
changeTypes: release.changeTypes ?? [],
reasonMarkdown: release.reasonMarkdown ?? "",
changesMarkdown: release.changesMarkdown ?? "",
userImpactMarkdown: release.userImpactMarkdown ?? "",
implementationImpactMarkdown: release.implementationImpactMarkdown ?? "",
verificationMarkdown: release.verificationMarkdown ?? "",
knownLimitationsMarkdown: release.knownLimitationsMarkdown ?? "",
};
}
const STATUS_LABELS: Readonly<Record<string, string>> = {
DRAFT: "작성 중",
PUBLISHED: "공개",
ARCHIVED: "보관",
};
/** 경로 파라미터는 `unknown` 으로 들어온다 — 라우터가 코덱을 통과시킨 값이라도 타입은 좁혀 써야 한다. */
function routeId(value: unknown): string {
return typeof value === "string" ? value : "";
}
export function ReleaseEditor() {
const { params } = useRouteInput<"TECH_LOG_STUDIO_RELEASE_EDIT">();
const releaseId = routeId(params.id);
const { managementGateway, setRequestAnnouncement } = useStudio();
const [draft, setDraft] = useState<Draft | null>(null);
const [status, setStatus] = useState("");
const [pending, setPending] = useState(false);
const [error, setError] = useState("");
const [generation, setGeneration] = useState(0);
const reload = useCallback(() => setGeneration((value) => value + 1), []);
useEffect(() => {
let cancelled = false;
void managementGateway.getRelease(releaseId).then(
(release) => {
if (cancelled) return;
setDraft(toDraft(release));
setStatus(release.workflowStatus);
setError("");
},
() => {
if (!cancelled) setError("릴리즈를 불러오지 못했습니다.");
},
);
return () => {
cancelled = true;
};
}, [managementGateway, releaseId, generation]);
const update = (patch: Partial<Draft>) =>
setDraft((current) => (current === null ? current : { ...current, ...patch }));
const requestOf = (current: Draft): ReleaseUpdateRequest =>
({
expectedVersion: current.expectedVersion,
versionLabel: current.versionLabel.trim(),
title: current.title.trim(),
summary: current.summary,
changeTypes: [...current.changeTypes],
changesMarkdown: current.changesMarkdown,
verificationMarkdown: current.verificationMarkdown,
...(current.releasedOn ? { releasedOn: current.releasedOn } : {}),
reasonMarkdown: current.reasonMarkdown,
userImpactMarkdown: current.userImpactMarkdown,
implementationImpactMarkdown: current.implementationImpactMarkdown,
knownLimitationsMarkdown: current.knownLimitationsMarkdown,
}) as ReleaseUpdateRequest;
const save = async () => {
if (pending || draft === null) return;
setPending(true);
setError("");
try {
const saved = await managementGateway.updateRelease(releaseId, requestOf(draft));
setDraft(toDraft(saved));
setRequestAnnouncement(`릴리즈 ${saved.versionLabel} 을(를) 저장했습니다.`);
reload();
} catch (failure) {
setError(managementFailureMessage(failure, "저장하지 못했습니다."));
} finally {
setPending(false);
}
};
const publish = async () => {
if (pending || draft === null) return;
setPending(true);
setError("");
try {
/*
. (`PublishReleaseUseCase`),
"모두 채워져야 합니다" . .
*/
const saved = await managementGateway.updateRelease(releaseId, requestOf(draft));
setDraft(toDraft(saved));
const published = await managementGateway.publishRelease(releaseId, saved.version);
setRequestAnnouncement(`릴리즈를 공개했습니다: ${published.canonicalPath}`);
reload();
} catch (failure) {
// 무엇이 모자란지는 서버가 안다. 예전에는 그 답을 버리고 필수 항목을 전부 나열했는데,
// 그러면 이미 채운 칸까지 비었다고 말하게 된다.
setError(
managementFailureMessage(
failure,
"공개하지 못했습니다. 버전, 제목, 한 줄 요약, 변경 유형, 변경 내용, 검증, 공개일이 모두 채워져야 합니다.",
),
);
} finally {
setPending(false);
}
};
const archive = async () => {
if (pending || draft === null) return;
setPending(true);
setError("");
try {
await managementGateway.archiveRelease(releaseId, draft.expectedVersion);
setRequestAnnouncement("릴리즈를 공개에서 내렸습니다.");
reload();
} catch (failure) {
setError(managementFailureMessage(failure, "공개에서 내리지 못했습니다."));
} finally {
setPending(false);
}
};
useSaveShortcut(save, draft !== null && !pending);
const toggleChangeType = (value: string) => {
if (draft === null) return;
update({
changeTypes: draft.changeTypes.includes(value)
? draft.changeTypes.filter((entry) => entry !== value)
: [...draft.changeTypes, value],
});
};
return (
<div className="studio-page studio-editor-page">
<header className="studio-page-top">
<div className="studio-page-heading">
<p className="studio-eyebrow">
RELEASE · {STATUS_LABELS[status] ?? status}
</p>
<h1>{draft?.title || "릴리즈"}</h1>
<p> .</p>
</div>
<GuardedStudioLink className="studio-primary-action" href="/studio/releases">
</GuardedStudioLink>
</header>
{error ? (
<p className="studio-screen-error" role="alert">
{error}
</p>
) : null}
{draft === null && !error ? (
<p className="studio-loading" role="status">
.
</p>
) : null}
{draft === null ? null : (
<section className="studio-editor-section" aria-labelledby="studio-release-edit-title">
<div className="studio-editor-section-heading">
<p className="studio-eyebrow">RELEASE · {draft.versionLabel}</p>
<h2 id="studio-release-edit-title">{draft.title || "릴리즈"}</h2>
<p> .</p>
</div>
<div className="studio-field-grid">
<label className="studio-field">
<span></span>
<input
type="text"
value={draft.versionLabel}
placeholder="0.1.0"
onChange={(event) => update({ versionLabel: event.currentTarget.value })}
/>
{/*
`draft-…` ,
(`ReleaseDrafts.isPlaceholder`).
.
*/}
{draft.versionLabel.startsWith("draft-") ? (
<span className="studio-field-notice studio-field-notice--error" role="alert">
. (: 0.2.0).
</span>
) : null}
</label>
<label className="studio-field">
<span></span>
<input
type="text"
value={draft.title}
onChange={(event) => update({ title: event.currentTarget.value })}
/>
</label>
<label className="studio-field">
<span></span>
<input
type="date"
value={draft.releasedOn}
onChange={(event) => update({ releasedOn: event.currentTarget.value })}
/>
</label>
<label className="studio-field studio-field--wide">
<span> </span>
<textarea
value={draft.summary}
onChange={(event) => update({ summary: event.currentTarget.value })}
/>
</label>
</div>
<fieldset className="studio-checkbox-set">
<legend> </legend>
{CHANGE_TYPES.map((changeType) => (
<label key={changeType.value} className="studio-field studio-field--checkbox">
<input
type="checkbox"
checked={draft.changeTypes.includes(changeType.value)}
onChange={() => toggleChangeType(changeType.value)}
/>
<span>{changeType.label}</span>
</label>
))}
</fieldset>
<div className="studio-field-grid">
{SECTIONS.map((section) => (
<label key={section.key} className="studio-field studio-field--wide">
<span>{section.label}</span>
<textarea
value={draft[section.key]}
onChange={(event) => update({ [section.key]: event.currentTarget.value })}
/>
</label>
))}
</div>
<div className="studio-create-footer">
<button
className="studio-primary-button"
type="button"
title="Ctrl+S"
disabled={pending}
onClick={() => void save()}
>
</button>
<button
className="studio-secondary-button"
type="button"
disabled={pending}
onClick={() => void publish()}
>
</button>
<button
className="studio-secondary-button"
type="button"
disabled={pending}
onClick={() => void archive()}
>
</button>
</div>
</section>
)}
</div>
);
}
@@ -6,6 +6,7 @@ import type {
ReleaseUpdateRequest, ReleaseUpdateRequest,
} from "../../../contracts/management/contract.ts"; } from "../../../contracts/management/contract.ts";
import { managementFailureMessage } from "../../../application/ports/management-gateway-error.ts"; import { managementFailureMessage } from "../../../application/ports/management-gateway-error.ts";
import { GuardedStudioLink } from "./guarded-studio-link.tsx";
import { useStudio } from "../use-studio.ts"; import { useStudio } from "../use-studio.ts";
/** /**
@@ -81,13 +82,13 @@ const STATUS_LABELS: Readonly<Record<string, string>> = {
export function ReleaseManager() { export function ReleaseManager() {
const { managementGateway, setRequestAnnouncement } = useStudio(); const { managementGateway, setRequestAnnouncement } = useStudio();
const [releases, setReleases] = useState<ReleaseIndexItem[] | null>(null); const [releases, setReleases] = useState<ReleaseIndexItem[] | null>(null);
const [selectedId, setSelectedId] = useState<string | null>(null);
const [draft, setDraft] = useState<Draft | null>(null);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [error, setError] = useState(""); const [error, setError] = useState("");
const [pending, setPending] = useState(false); const [pending, setPending] = useState(false);
const [generation, setGeneration] = useState(0); const [generation, setGeneration] = useState(0);
const [newTitle, setNewTitle] = useState(""); const [newTitle, setNewTitle] = useState("");
/* 방금 만든 릴리즈를 목록에서 짚어 준다 — 목록이 길면 어느 것이 새것인지 알기 어렵다. */
const [createdId, setCreatedId] = useState<string | null>(null);
const reload = useCallback(() => setGeneration((value) => value + 1), []); const reload = useCallback(() => setGeneration((value) => value + 1), []);
@@ -112,29 +113,6 @@ export function ReleaseManager() {
}; };
}, [managementGateway, generation]); }, [managementGateway, generation]);
// 선택된 릴리즈의 본문은 목록에 없다 — 목록 행은 마크다운을 싣지 않으므로 따로 읽는다.
useEffect(() => {
if (selectedId === null) {
setDraft(null);
return undefined;
}
let cancelled = false;
void managementGateway.getRelease(selectedId).then(
(release) => {
if (!cancelled) setDraft(toDraft(release));
},
() => {
if (!cancelled) setError("릴리즈를 불러오지 못했습니다.");
},
);
return () => {
cancelled = true;
};
}, [managementGateway, selectedId, generation]);
const update = (patch: Partial<Draft>) =>
setDraft((current) => (current === null ? current : { ...current, ...patch }));
const submitNew = async (event: FormEvent) => { const submitNew = async (event: FormEvent) => {
event.preventDefault(); event.preventDefault();
if (pending) return; if (pending) return;
@@ -148,8 +126,10 @@ export function ReleaseManager() {
try { try {
const created = await managementGateway.createRelease(title); const created = await managementGateway.createRelease(title);
setNewTitle(""); setNewTitle("");
setRequestAnnouncement(`릴리즈 ${title} 초안을 만들었습니다.`); setRequestAnnouncement(
setSelectedId(created.id); `릴리즈 ${title} 초안을 만들었습니다. 목록에서 편집을 눌러 내용을 채웁니다.`,
);
setCreatedId(created.id);
reload(); reload();
} catch (error) { } catch (error) {
setError(managementFailureMessage(error, "릴리즈를 만들지 못했습니다.")); setError(managementFailureMessage(error, "릴리즈를 만들지 못했습니다."));
@@ -158,77 +138,12 @@ export function ReleaseManager() {
} }
}; };
const save = async () => {
if (pending || draft === null || selectedId === null) return;
setPending(true);
setError("");
try {
const body: ReleaseUpdateRequest = {
expectedVersion: draft.expectedVersion,
versionLabel: draft.versionLabel.trim(),
title: draft.title.trim(),
summary: draft.summary,
changeTypes: [...draft.changeTypes],
changesMarkdown: draft.changesMarkdown,
verificationMarkdown: draft.verificationMarkdown,
...(draft.releasedOn ? { releasedOn: draft.releasedOn } : {}),
reasonMarkdown: draft.reasonMarkdown,
userImpactMarkdown: draft.userImpactMarkdown,
implementationImpactMarkdown: draft.implementationImpactMarkdown,
knownLimitationsMarkdown: draft.knownLimitationsMarkdown,
} as ReleaseUpdateRequest;
const saved = await managementGateway.updateRelease(selectedId, body);
setDraft(toDraft(saved));
setRequestAnnouncement(`릴리즈 ${saved.versionLabel} 을(를) 저장했습니다.`);
reload();
} catch (error) {
setError(managementFailureMessage(error, "저장하지 못했습니다."));
} finally {
setPending(false);
}
};
const publish = async () => {
if (pending || draft === null || selectedId === null) return;
setPending(true);
setError("");
try {
const published = await managementGateway.publishRelease(selectedId, draft.expectedVersion);
setRequestAnnouncement(`릴리즈를 공개했습니다: ${published.canonicalPath}`);
reload();
} catch {
// 발행은 저장보다 요구가 많다. 무엇이 비었는지는 서버가 알고 있지만, 그 목록을 그대로
// 옮기려면 오류 details 를 읽는 화면이 필요하다 — 여기서는 필수 항목을 그대로 안내한다.
setError(
"공개하지 못했습니다. 버전, 제목, 한 줄 요약, 변경 유형, 변경 내용, 검증, 공개일이 모두 채워져야 합니다.",
);
} finally {
setPending(false);
}
};
const archive = async () => {
if (pending || draft === null || selectedId === null) return;
setPending(true);
setError("");
try {
await managementGateway.archiveRelease(selectedId, draft.expectedVersion);
setRequestAnnouncement("릴리즈를 공개에서 내렸습니다.");
reload();
} catch (error) {
setError(managementFailureMessage(error, "공개에서 내리지 못했습니다."));
} finally {
setPending(false);
}
};
const remove = async (release: ReleaseIndexItem) => { const remove = async (release: ReleaseIndexItem) => {
if (pending) return; if (pending) return;
setPending(true); setPending(true);
setError(""); setError("");
try { try {
await managementGateway.deleteRelease(release.id, release.version); await managementGateway.deleteRelease(release.id, release.version);
if (selectedId === release.id) setSelectedId(null);
setRequestAnnouncement(`릴리즈 ${release.versionLabel} 을(를) 삭제했습니다.`); setRequestAnnouncement(`릴리즈 ${release.versionLabel} 을(를) 삭제했습니다.`);
reload(); reload();
} catch (error) { } catch (error) {
@@ -238,14 +153,6 @@ export function ReleaseManager() {
} }
}; };
const toggleChangeType = (value: string) => {
if (draft === null) return;
const next = draft.changeTypes.includes(value)
? draft.changeTypes.filter((entry) => entry !== value)
: [...draft.changeTypes, value];
update({ changeTypes: next });
};
return ( return (
<div className="studio-page studio-documents-page"> <div className="studio-page studio-documents-page">
<header className="studio-page-top"> <header className="studio-page-top">
@@ -292,29 +199,38 @@ export function ReleaseManager() {
<article <article
key={release.id} key={release.id}
className="studio-document-row" className="studio-document-row"
aria-current={selectedId === release.id ? "true" : undefined} aria-current={createdId === release.id ? "true" : undefined}
> >
{/*
.
· .
*/}
<p className="studio-row-label">RELEASE</p>
<div className="studio-document-title"> <div className="studio-document-title">
<h2>{release.title}</h2> <h2>{release.title}</h2>
<p> <p>{release.publication.canonicalPath ?? "아직 공개하지 않음"}</p>
{release.versionLabel}
{release.releasedOn ? ` · ${release.releasedOn}` : ""}
</p>
</div> </div>
<dl> <dl>
<div>
<dt></dt>
<dd>{release.versionLabel}</dd>
</div>
<div> <div>
<dt></dt> <dt></dt>
<dd>{STATUS_LABELS[release.workflowStatus] ?? release.workflowStatus}</dd> <dd>{STATUS_LABELS[release.workflowStatus] ?? release.workflowStatus}</dd>
</div> </div>
<div>
<dt></dt>
<dd>{release.releasedOn || "미정"}</dd>
</div>
</dl> </dl>
<button <div className="studio-row-actions">
<GuardedStudioLink
className="studio-secondary-button" className="studio-secondary-button"
type="button" href={`/studio/releases/${release.id}`}
disabled={pending}
onClick={() => setSelectedId(selectedId === release.id ? null : release.id)}
> >
{selectedId === release.id ? "닫기" : "편집"}
</button> </GuardedStudioLink>
<button <button
className="studio-secondary-button" className="studio-secondary-button"
type="button" type="button"
@@ -323,106 +239,13 @@ export function ReleaseManager() {
> >
</button> </button>
</div>
</article> </article>
))} ))}
</div> </div>
</section> </section>
)} )}
{draft === null ? null : (
<section aria-label="릴리즈 편집">
<h2 className="studio-section-title">{draft.title || "릴리즈"} </h2>
<div className="studio-field-grid">
<label className="studio-field">
<span></span>
<input
type="text"
value={draft.versionLabel}
placeholder="0.1.0"
onChange={(event) => update({ versionLabel: event.currentTarget.value })}
/>
</label>
<label className="studio-field">
<span></span>
<input
type="text"
value={draft.title}
onChange={(event) => update({ title: event.currentTarget.value })}
/>
</label>
<label className="studio-field">
<span></span>
<input
type="date"
value={draft.releasedOn}
onChange={(event) => update({ releasedOn: event.currentTarget.value })}
/>
</label>
<label className="studio-field studio-field--wide">
<span> </span>
<textarea
value={draft.summary}
onChange={(event) => update({ summary: event.currentTarget.value })}
/>
</label>
</div>
<fieldset className="studio-field-grid">
<legend className="studio-field">
<span> </span>
</legend>
{CHANGE_TYPES.map((changeType) => (
<label key={changeType.value} className="studio-field studio-field--checkbox">
<input
type="checkbox"
checked={draft.changeTypes.includes(changeType.value)}
onChange={() => toggleChangeType(changeType.value)}
/>
<span>{changeType.label}</span>
</label>
))}
</fieldset>
<div className="studio-field-grid">
{SECTIONS.map((section) => (
<label key={section.key} className="studio-field studio-field--wide">
<span>{section.label}</span>
<textarea
value={draft[section.key]}
onChange={(event) => update({ [section.key]: event.currentTarget.value })}
/>
</label>
))}
</div>
<div className="studio-create-footer">
<button
className="studio-primary-button"
type="button"
disabled={pending}
onClick={() => void save()}
>
</button>
<button
className="studio-secondary-button"
type="button"
disabled={pending}
onClick={() => void publish()}
>
</button>
<button
className="studio-secondary-button"
type="button"
disabled={pending}
onClick={() => void archive()}
>
</button>
</div>
</section>
)}
</div> </div>
); );
} }
@@ -4,6 +4,7 @@ import type { StudioDashboard as StudioDashboardData } from "../../../contracts/
import type { components } from "../../../contracts/studio/generated.ts"; import type { components } from "../../../contracts/studio/generated.ts";
import { useStudio } from "../use-studio.ts"; import { useStudio } from "../use-studio.ts";
import { GuardedStudioLink } from "./guarded-studio-link.tsx"; import { GuardedStudioLink } from "./guarded-studio-link.tsx";
import { HomeFocusEditor } from "./home-focus-editor.tsx";
type DocumentSummary = components["schemas"]["DocumentSummary"]; type DocumentSummary = components["schemas"]["DocumentSummary"];
@@ -158,6 +159,7 @@ export function StudioDashboard() {
href="/studio/documents" href="/studio/documents"
empty="게시 준비가 끝난 문서가 없습니다." empty="게시 준비가 끝난 문서가 없습니다."
/> />
<HomeFocusEditor />
<section className="studio-work-section"> <section className="studio-work-section">
<div className="studio-section-title"> <div className="studio-section-title">
<h2> </h2> <h2> </h2>
@@ -2,6 +2,7 @@ import { useCallback, useEffect, useState, type FormEvent } from "react";
import type { ProjectIndexItem, TopicEdit } from "../../../contracts/management/contract.ts"; import type { ProjectIndexItem, TopicEdit } from "../../../contracts/management/contract.ts";
import { managementFailureMessage } from "../../../application/ports/management-gateway-error.ts"; import { managementFailureMessage } from "../../../application/ports/management-gateway-error.ts";
import { GuardedStudioLink } from "./guarded-studio-link.tsx";
import { slugFromName } from "./slug-from-name.ts"; import { slugFromName } from "./slug-from-name.ts";
import { useStudio } from "../use-studio.ts"; import { useStudio } from "../use-studio.ts";
@@ -15,6 +16,23 @@ import { useStudio } from "../use-studio.ts";
* <p> CSS . Studio * <p> CSS . Studio
* ·· . * ·· .
*/ */
/** 공개 프로젝트 화면과 같은 말을 쓴다 — 두 화면이 다른 낱말을 쓰면 같은 것인지 알기 어렵다. */
const phaseLabel: Record<string, string> = {
RESEARCH: "조사",
DESIGN: "설계",
IMPLEMENTATION: "구현",
VERIFICATION: "검증",
MAINTENANCE: "유지",
PAUSED: "중단",
COMPLETED: "완료",
};
const visibilityLabel: Record<string, string> = {
PRIVATE: "비공개",
UNLISTED: "주소로만",
PUBLIC: "공개",
};
export function TaxonomyManager() { export function TaxonomyManager() {
const { managementGateway, setRequestAnnouncement } = useStudio(); const { managementGateway, setRequestAnnouncement } = useStudio();
const [topics, setTopics] = useState<TopicEdit[] | null>(null); const [topics, setTopics] = useState<TopicEdit[] | null>(null);
@@ -159,6 +177,37 @@ export function TaxonomyManager() {
} }
}; };
/*
. ,
( · "현재 프로젝트"· focus) .
.
*/
const togglePublish = async (project: ProjectIndexItem) => {
if (pending) return;
setPending(true);
setError("");
const published = project.targetVisibility !== "PRIVATE";
try {
if (published) {
await managementGateway.unpublishProject(project.id, project.version);
setRequestAnnouncement(`프로젝트 ${project.name} 을(를) 비공개로 되돌렸습니다.`);
} else {
await managementGateway.publishProject(project.id, project.version, "PUBLIC");
setRequestAnnouncement(`프로젝트 ${project.name} 을(를) 공개했습니다.`);
}
reload();
} catch (error) {
setError(
managementFailureMessage(
error,
published ? "프로젝트를 비공개로 되돌리지 못했습니다." : "프로젝트를 게시하지 못했습니다.",
),
);
} finally {
setPending(false);
}
};
return ( return (
<div className="studio-page studio-documents-page"> <div className="studio-page studio-documents-page">
<header className="studio-page-top"> <header className="studio-page-top">
@@ -269,19 +318,45 @@ export function TaxonomyManager() {
<article className="studio-document-row" key={project.id}> <article className="studio-document-row" key={project.id}>
<p className="studio-row-label">PROJECT</p> <p className="studio-row-label">PROJECT</p>
<div className="studio-document-title"> <div className="studio-document-title">
<h2>{project.name}</h2> <h2>
<GuardedStudioLink href={`/studio/projects/${project.id}`}>
{project.name}
</GuardedStudioLink>
</h2>
<p>{project.currentObjective ?? "목표 미지정"}</p> <p>{project.currentObjective ?? "목표 미지정"}</p>
</div> </div>
<dl> <dl>
<div> <div>
<dt></dt> <dt></dt>
<dd>{project.phase}</dd> <dd>{phaseLabel[project.phase] ?? project.phase}</dd>
</div> </div>
<div> <div>
<dt></dt> <dt></dt>
<dd>{project.targetVisibility}</dd> {/*
"게시됨" UNLISTED
.
.
*/}
<dd>{visibilityLabel[project.targetVisibility] ?? project.targetVisibility}</dd>
</div>
<div>
<dt></dt>
<dd>
<GuardedStudioLink href={`/studio/projects/${project.id}`}>
</GuardedStudioLink>
</dd>
</div> </div>
</dl> </dl>
<div className="studio-row-actions">
<button
className="studio-secondary-button"
type="button"
disabled={pending}
onClick={() => void togglePublish(project)}
>
{project.targetVisibility === "PRIVATE" ? "게시" : "게시 취소"}
</button>
<button <button
className="studio-secondary-button" className="studio-secondary-button"
type="button" type="button"
@@ -290,6 +365,7 @@ export function TaxonomyManager() {
> >
</button> </button>
</div>
</article> </article>
))} ))}
</div> </div>
@@ -0,0 +1,29 @@
import { useEffect } from "react";
/**
* Ctrl+S( Cmd+S) .
*
* <p> "페이지를 파일로 저장".
* .
* .
*
* <p>`enabled` .
* "지금 저장할 수 있는가" .
*/
export function useSaveShortcut(
save: () => void | Promise<void>,
enabled = true,
) {
useEffect(() => {
function onKeyDown(event: KeyboardEvent) {
if (event.key !== "s" && event.key !== "S") return;
if (!(event.ctrlKey || event.metaKey)) return;
// Ctrl+Shift+S 는 다른 뜻으로 쓰는 곳이 있어 가로채지 않는다.
if (event.shiftKey || event.altKey) return;
event.preventDefault();
if (enabled) void save();
}
window.addEventListener("keydown", onKeyDown);
return () => window.removeEventListener("keydown", onKeyDown);
}, [enabled, save]);
}
@@ -0,0 +1,5 @@
import { ProjectEditor } from "../components/project-editor.tsx";
export function ProjectEditPage() {
return <ProjectEditor />;
}
@@ -0,0 +1,5 @@
import { ReleaseEditor } from "../components/release-editor.tsx";
export function ReleaseEditPage() {
return <ReleaseEditor />;
}
@@ -17,7 +17,12 @@
--warning-soft: #fff7ed; --warning-soft: #fff7ed;
--code-canvas: #15181d; --code-canvas: #15181d;
--shell: 1180px; --shell: 1180px;
--body-copy: 42rem; /*
읽는 단의 . 본문·코드블록··콜아웃·그림이 모두 값을 쓰므로 곳만 바꾸면 전체가 함께
움직인다. 42rem(672px) 영문 기준 측정값이었고, 한글 본문에서는 좁아 보이는 데다 바로 위의
유형/프로젝트/게시 줄이 shell 전체(1180px) 쓰고 있어 대비가 심했다.
*/
--body-copy: 56rem;
} }
* { * {
@@ -95,6 +100,102 @@ a {
same reserved height: with only `main` covered the footer still sat at the same reserved height: with only `main` covered the footer still sat at the
viewport bottom during loading and then dropped out of view when the content viewport bottom during loading and then dropped out of view when the content
arrived, which is the whole 0.192 the page scored. */ arrived, which is the whole 0.192 the page scored. */
/*
로그인 관문.
화면은 공개 셸과 Studio 양쪽에서 나온다 보호된 주소를 열면 주소가 속한 레이아웃 안에
자리가 대신 그려진다. 그래서 모두에 있는 토큰만 쓴다.
카드로 세우는 이유는 이것이 "화면을 못 그렸다" 아니라 "여기서 한 걸음 더 필요하다"이기
때문이다. 오류 화면과 같은 모양이면 작성자는 무언가 고장 줄로 읽는다.
*/
.auth-gate {
display: grid;
place-items: center;
padding-block: clamp(64px, 12vh, 140px);
}
.auth-gate .auth-gate__card {
width: min(100%, 34rem);
padding: clamp(32px, 5vw, 52px);
border: 1px solid var(--line-strong);
border-radius: 10px;
background: var(--paper);
}
.auth-gate .page-header {
margin: 0;
}
.auth-gate .page-header__eyebrow {
margin: 0 0 14px;
color: var(--signal);
font-family: "IBM Plex Mono", monospace;
font-size: 12px;
font-weight: 600;
letter-spacing: 0.08em;
}
.auth-gate .page-header h1 {
margin: 0;
font-size: clamp(27px, 3.2vw, 33px);
font-weight: 660;
letter-spacing: -0.035em;
line-height: 1.25;
}
.auth-gate .page-header__description {
margin: 16px 0 0;
color: var(--ink-soft);
font-size: 16px;
line-height: 1.72;
}
.auth-gate .auth-gate__actions {
display: grid;
gap: 14px;
margin-top: 32px;
padding-top: 28px;
border-top: 1px solid var(--line);
}
/* 이 버튼은 화면에서 유일한 행동이다. 폭을 채워 어디를 눌러야 하는지 묻지 않게 한다. */
.auth-gate .auth-gate__actions button {
display: inline-flex;
min-height: 48px;
align-items: center;
justify-content: center;
width: 100%;
padding-inline: 20px;
border: 1px solid var(--signal);
border-radius: 6px;
background: var(--signal);
color: #fff;
font: inherit;
font-size: 15px;
font-weight: 650;
cursor: pointer;
}
.auth-gate .auth-gate__actions button:disabled {
opacity: 0.55;
cursor: wait;
}
.auth-gate .auth-gate__return {
margin: 0;
color: var(--faint);
font-size: 13px;
line-height: 1.6;
overflow-wrap: anywhere;
}
.auth-gate .ui-terminal-error {
margin: 20px 0 0;
color: var(--warning);
font-size: 14px;
}
.site-frame > main, .site-frame > main,
.site-frame > .ui-page { .site-frame > .ui-page {
/* Takes the slack so the footer stays put whether the route rendered a long /* Takes the slack so the footer stays put whether the route rendered a long
@@ -1329,11 +1430,18 @@ dialog::backdrop {
text-align: center; text-align: center;
} }
/* 모달 dialog 화면 가운데에 둔다. UA 기본값(margin:auto) 맡기면 빌드에서는 좌상단에
붙는다 .search-dialog Studio .studio-unsaved-dialog 같은 이유로 position/inset/
margin 명시한다. 여기만 빠져 있어 "크게 보기" 왼쪽 위에 열렸다. */
.figure-dialog { .figure-dialog {
position: fixed;
inset: 0;
margin: auto;
width: min(1180px, calc(100% - 48px)); width: min(1180px, calc(100% - 48px));
max-width: none; max-width: none;
max-height: calc(100dvh - 48px);
padding: 0; padding: 0;
overflow: hidden; overflow: auto;
border: 1px solid var(--line-strong); border: 1px solid var(--line-strong);
border-radius: 9px; border-radius: 9px;
background: var(--paper); background: var(--paper);
@@ -2174,6 +2282,20 @@ dialog::backdrop {
max-width: 920px; max-width: 920px;
} }
/*
문서 편은 하나의 단으로 읽힌다.
예전에는 조각이 저마다 다른 폭이었다 머리말 920px, 사실 줄은 shell 전체 1180px, 본문은
672px 가운데 정렬. 그래서 눈이 왼쪽 끝을 다시 찾아야 했고, 가장 좁은 본문이 가장 넓은
사실 안에 갇힌 것처럼 보였다. 셋을 같은 ·같은 왼쪽 끝에 세운다.
*/
.public-document-header,
.document-snapshot,
.document-facts {
width: min(100%, var(--body-copy));
max-width: var(--body-copy);
}
.public-page-header h1, .public-page-header h1,
.public-document-header h1 { .public-document-header h1 {
margin: 15px 0 18px; margin: 15px 0 18px;
@@ -2297,7 +2419,7 @@ dialog::backdrop {
.reference-purpose, .reference-purpose,
.document-relations { .document-relations {
width: min(100%, var(--body-copy)); width: min(100%, var(--body-copy));
margin: 82px auto 0; margin: 82px 0 0;
} }
.public-document-body > section + section { margin-top: 76px; padding-top: 70px; border-top: 1px solid var(--line); } .public-document-body > section + section { margin-top: 76px; padding-top: 70px; border-top: 1px solid var(--line); }
.public-document-body h2, .public-document-body h2,
@@ -2613,3 +2735,29 @@ textarea.studio-control { min-height: 118px; resize: vertical; line-height: 1.65
.studio-diff-grid section, .studio-diff-grid section + section { min-height: 0; padding: 23px 0; border-left: 0; } .studio-diff-grid section, .studio-diff-grid section + section { min-height: 0; padding: 23px 0; border-left: 0; }
.studio-diff-grid section + section { border-top: 1px solid var(--line); } .studio-diff-grid section + section { border-top: 1px solid var(--line); }
} }
/* 본문 구분선과 그림. 문서 본문의 다른 블록과 같은 세로 리듬을 따른다. */
.document-body > section > .document-rule {
margin: 34px 0;
border: 0;
border-top: 1px solid var(--line);
}
.document-body > section > .document-image {
margin: 26px 0;
}
.document-body > section > .document-image img {
display: block;
width: 100%;
height: auto;
border: 1px solid var(--line);
border-radius: 6px;
}
.document-body > section > .document-image figcaption {
margin-top: 10px;
color: var(--muted);
font-size: 13px;
line-height: 1.6;
}
@@ -1,15 +1,37 @@
.studio-app .studio-editor-page, .studio-app .studio-editor-page,
.studio-app .studio-editor-layout, .studio-app .studio-editor-split,
.studio-app .studio-editor-workspace, .studio-app .studio-editor-workspace,
.studio-app .studio-editor-workspace > [role="tabpanel"] { min-width: 0; } .studio-app .studio-editor-preview { min-width: 0; }
.studio-app .studio-editor-loading { min-height: 240px; padding-block: 56px; color: var(--muted); } .studio-app .studio-editor-loading { min-height: 240px; padding-block: 56px; color: var(--muted); }
.studio-app .studio-editor-tabs { display: flex; gap: 28px; border-bottom: 1px solid var(--line-strong); } /*
.studio-app .studio-editor-tabs button { position: relative; min-width: 112px; min-height: 44px; padding: 0; border: 0; background: transparent; color: var(--muted); font: inherit; font-weight: 650; } 편집과 미리보기를 화면에 나란히 둔다. 탭이었을 때는 번에 하나만 보였고, 고친 결과를
.studio-app .studio-editor-tabs button[aria-selected="true"] { color: var(--ink); } 보려면 편집하던 자리를 화면에서 치워야 했다.
.studio-app .studio-editor-tabs button[aria-selected="true"]::after { position: absolute; right: 0; bottom: -1px; left: 0; height: 2px; background: var(--signal); content: ""; } */
/*
화면만 공용 컨테이너보다 넓다. `.studio-main` 모든 Studio 화면이 1180px 함께
쓰는데, 여기서는 폭에 본문 벌이 들어가야 해서 칸이 566px 좁아졌다. 헤더와
다른 화면은 그대로 두고 편집기가 놓인 경우에만 넓힌다.
*/
.studio-app:has(.studio-editor-split) .studio-header-inner,
.studio-app:has(.studio-editor-split) .studio-main { width: min(1600px, calc(100% - 80px)); }
.studio-app .studio-editor-layout { display: grid; grid-template-columns: minmax(0, 1fr) minmax(240px, 280px); gap: 64px; align-items: start; padding-top: 40px; } /*
편집과 미리보기를 화면에 나란히 둔다. 탭이었을 때는 번에 하나만 보였고, 고친 결과를
보려면 편집하던 자리를 화면에서 치워야 했다.
모두 페이지 스크롤을 그대로 탄다. 미리보기를 붙박이로 두고 자체 스크롤을 주었더니
편집기를 내려도 미리보기는 제자리였고, 보려면 안을 따로 굴려야 했다 나란히 이유가
둘을 같이 보는 것인데 움직임이 갈라지면 이점이 없다.
*/
.studio-app .studio-editor-split { display: grid; grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); gap: 48px; padding-top: 40px; }
/* 반쪽 폭에는 62px 짜리 제목이 앉을 자리가 없다. */
.studio-app .studio-editor-split .studio-editor-heading h1 { font-size: clamp(28px, 3vw, 40px); }
.studio-app .studio-editor-preview { display: flex; flex-direction: column; padding-left: 40px; border-left: 1px solid var(--line); }
.studio-app .studio-editor-preview__heading { padding-bottom: 20px; border-bottom: 1px solid var(--line); }
.studio-app .studio-editor-preview__heading .studio-eyebrow { margin-bottom: 8px; }
.studio-app .studio-editor-preview__body { padding-top: 20px; }
.studio-app .studio-editor-heading { padding-bottom: 36px; border-bottom: 1px solid var(--line-strong); } .studio-app .studio-editor-heading { padding-bottom: 36px; border-bottom: 1px solid var(--line-strong); }
.studio-app .studio-editor-heading h1 { margin: 0; font-size: clamp(38px, 5vw, 62px); line-height: 1.05; letter-spacing: -0.045em; } .studio-app .studio-editor-heading h1 { margin: 0; font-size: clamp(38px, 5vw, 62px); line-height: 1.05; letter-spacing: -0.045em; }
.studio-app .studio-editor-heading > p:last-child { max-width: 780px; margin: 18px 0 0; color: var(--muted); font-size: 17px; line-height: 1.65; overflow-wrap: anywhere; } .studio-app .studio-editor-heading > p:last-child { max-width: 780px; margin: 18px 0 0; color: var(--muted); font-size: 17px; line-height: 1.65; overflow-wrap: anywhere; }
@@ -17,7 +39,7 @@
.studio-app .studio-editor-section-heading { margin-bottom: 24px; } .studio-app .studio-editor-section-heading { margin-bottom: 24px; }
.studio-app .studio-editor-section-heading .studio-eyebrow { margin-bottom: 8px; } .studio-app .studio-editor-section-heading .studio-eyebrow { margin-bottom: 8px; }
.studio-app .studio-editor-section-heading h2, .studio-app .studio-editor-section-heading h2,
.studio-app .studio-document-status-rail h2, .studio-app .studio-editor-preview__heading h2,
.studio-app .studio-preview-error h2 { margin: 0; font-size: 25px; letter-spacing: -0.03em; } .studio-app .studio-preview-error h2 { margin: 0; font-size: 25px; letter-spacing: -0.03em; }
.studio-app .studio-field-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 22px; } .studio-app .studio-field-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 22px; }
@@ -36,6 +58,31 @@
.studio-app .studio-ordered-item textarea { min-height: 112px; resize: vertical; } .studio-app .studio-ordered-item textarea { min-height: 112px; resize: vertical; }
.studio-app .studio-field .studio-markdown-field { min-height: 420px; font-family: "IBM Plex Mono", monospace; font-size: 13px; } .studio-app .studio-field .studio-markdown-field { min-height: 420px; font-family: "IBM Plex Mono", monospace; font-size: 13px; }
/* 칸 아래 한 줄 설명. 라벨과 같은 격자 안에 들어가므로 별도 여백을 두지 않는다. */
.studio-app .studio-field > small { color: var(--faint); font-size: 12px; font-weight: 450; line-height: 1.6; }
/* 여러 개를 고르는 목록(주제 등). 작업본 목록의 유형 선택과 같은 모양을 쓴다. */
.studio-app .studio-choice-list { display: grid; grid-template-columns: repeat(auto-fill, minmax(240px, 1fr)); gap: 0 24px; margin: 0; padding: 0; list-style: none; border-top: 1px solid var(--line); }
.studio-app .studio-choice { display: grid; grid-template-columns: 20px minmax(0, 1fr); gap: 4px 12px; align-items: center; min-height: 60px; padding: 12px 0; border-bottom: 1px solid var(--line); cursor: pointer; }
.studio-app .studio-choice input { width: 20px; min-width: 20px; min-height: 20px; padding: 0; }
.studio-app .studio-choice span { font-size: 15px; font-weight: 550; }
.studio-app .studio-choice small { grid-column: 2; color: var(--faint); font-family: "IBM Plex Mono", monospace; font-size: 11px; }
/* 편집 화면 하단의 저장 줄. 버튼과 그 옆의 상태 한 줄. */
.studio-app .studio-editor-footer { display: flex; flex-wrap: wrap; align-items: center; gap: 16px; padding: 32px 0 0; }
.studio-app .studio-editor-footer .studio-primary-button,
.studio-app .studio-editor-footer .studio-secondary-button { margin: 0; }
.studio-app .studio-editor-footer .studio-field-note { flex: 1 1 260px; margin: 0; color: var(--muted); font-size: 12px; line-height: 1.6; }
/* 체크박스 묶음(변경 유형 등). `.studio-field-grid` 를 쓰면 legend 가 한 칸을 차지해 줄이 어긋난다. */
.studio-app .studio-checkbox-set { margin: 34px 0 0; padding: 0; border: 0; }
.studio-app .studio-checkbox-set legend { padding: 0 0 14px; color: var(--muted); font-size: 12px; font-weight: 650; }
.studio-app .studio-checkbox-set { display: grid; grid-template-columns: repeat(auto-fill, minmax(180px, 1fr)); gap: 0 24px; }
.studio-app .studio-checkbox-set legend { grid-column: 1 / -1; }
.studio-app .studio-checkbox-set .studio-field--checkbox { display: grid; grid-template-columns: 20px minmax(0, 1fr); gap: 12px; align-items: center; min-height: 52px; padding: 8px 0; border-bottom: 1px solid var(--line); cursor: pointer; }
.studio-app .studio-checkbox-set .studio-field--checkbox input { width: 20px; min-width: 20px; min-height: 20px; padding: 0; }
.studio-app .studio-checkbox-set .studio-field--checkbox span { font-size: 15px; font-weight: 500; }
.studio-app .studio-ordered-list, .studio-app .studio-ordered-list,
.studio-app .studio-resolution-fields { min-width: 0; margin: 34px 0 0; padding: 0; border: 0; } .studio-app .studio-resolution-fields { min-width: 0; margin: 34px 0 0; padding: 0; border: 0; }
.studio-app .studio-ordered-list legend, .studio-app .studio-ordered-list legend,
@@ -46,27 +93,38 @@
.studio-app .studio-item-actions { display: flex; flex-wrap: wrap; gap: 8px; } .studio-app .studio-item-actions { display: flex; flex-wrap: wrap; gap: 8px; }
.studio-app .studio-item-actions button, .studio-app .studio-item-actions button,
.studio-app .studio-add-item, .studio-app .studio-add-item,
.studio-app .studio-document-status-rail button { min-height: 44px; padding-inline: 13px; border: 1px solid var(--line-strong); border-radius: 5px; background: var(--paper); color: var(--ink); font-weight: 650; } .studio-app .studio-document-status-bar button { min-height: 44px; padding-inline: 13px; border: 1px solid var(--line-strong); border-radius: 5px; background: var(--paper); color: var(--ink); font-weight: 650; }
.studio-app .studio-item-actions button:disabled, .studio-app .studio-item-actions button:disabled,
.studio-app .studio-add-item:disabled { opacity: 0.45; } .studio-app .studio-add-item:disabled { opacity: 0.45; }
.studio-app .studio-add-item { margin-top: 12px; } .studio-app .studio-add-item { margin-top: 12px; }
.studio-app .studio-resolution-fields { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 22px; } .studio-app .studio-resolution-fields { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 22px; }
.studio-app .studio-resolution-fields legend { grid-column: 1 / -1; } .studio-app .studio-resolution-fields legend { grid-column: 1 / -1; }
.studio-app .studio-document-status-rail { position: sticky; top: 28px; min-width: 0; padding: 24px 0; border-top: 1px solid var(--line-strong); border-bottom: 1px solid var(--line-strong); } /*
.studio-app .studio-document-status-rail .studio-eyebrow { margin-bottom: 8px; } 작업 상태와 저장·게시를 화면 아래에 붙여 둔다. 문서가 길어져도 스크롤 위치와 무관하게 손이
.studio-app .studio-editor-status { margin: 20px 0; font-weight: 700; } 닿는다. 배경을 불투명하게 두는 것은 장식이 아니라 필요다 밑으로 지나가는 본문이 비치면
상태 글자를 읽을 없다.
*/
.studio-app .studio-document-status-bar { position: sticky; bottom: 0; z-index: 2; display: flex; flex-wrap: wrap; align-items: center; gap: 12px 28px; min-width: 0; margin-top: 48px; padding: 16px 0; border-top: 1px solid var(--line-strong); background: var(--paper); }
.studio-app .studio-editor-status { margin: 0; font-weight: 700; }
.studio-app .studio-editor-status--dirty, .studio-app .studio-editor-status--dirty,
.studio-app .studio-editor-status--conflict, .studio-app .studio-editor-status--conflict,
.studio-app .studio-editor-conflict { color: #8f2f27; } .studio-app .studio-editor-conflict { color: #8f2f27; }
.studio-app .studio-editor-status--clean { color: #166748; } .studio-app .studio-editor-status--clean { color: #166748; }
.studio-app .studio-document-status-rail dl { display: grid; gap: 12px; margin: 0 0 20px; } .studio-app .studio-document-status-bar dl { display: flex; gap: 20px; margin: 0; }
.studio-app .studio-document-status-rail dl div { display: flex; justify-content: space-between; gap: 16px; } .studio-app .studio-document-status-bar dl div { display: flex; align-items: baseline; gap: 8px; }
.studio-app .studio-document-status-rail dt { color: var(--muted); font-size: 12px; } .studio-app .studio-document-status-bar dt { color: var(--muted); font-size: 12px; }
.studio-app .studio-document-status-rail dd { margin: 0; font-size: 13px; } .studio-app .studio-document-status-bar dd { margin: 0; font-size: 13px; }
.studio-app .studio-document-status-rail button { width: 100%; border-color: var(--signal); background: var(--signal); color: #fff; } .studio-app .studio-document-status-bar__note { flex: 1 1 240px; margin: 0; color: var(--muted); font-size: 12px; line-height: 1.6; }
.studio-app .studio-document-status-rail button:disabled { border-color: var(--line-strong); background: var(--paper); color: var(--muted); } /* 색은 위 묶음이 정한다 — 여기서 다시 칠하면 실패가 안내문처럼 보인다. */
.studio-app .studio-document-status-rail > p:last-child { margin: 16px 0 0; color: var(--muted); font-size: 12px; line-height: 1.65; } .studio-app .studio-document-status-bar .studio-editor-conflict { flex: 1 1 240px; margin: 0; font-size: 12px; line-height: 1.6; }
.studio-app .studio-document-status-bar__actions { display: flex; align-items: center; gap: 12px; margin-left: auto; }
/* `.studio-primary-button` 세로로 쌓이는 자리에 맞춘 바깥 여백을 갖고 있다. 줄에
나란히 두면 여백이 높이를 늘리고, 버튼만 늘어나 짝이 어긋난다. */
.studio-app .studio-document-status-bar__actions .studio-primary-button { margin: 0; }
.studio-app .studio-document-status-bar button { min-width: 104px; border-color: var(--signal); background: var(--signal); color: #fff; }
.studio-app .studio-document-status-bar button:disabled { border-color: var(--line-strong); background: var(--paper); color: var(--muted); }
.studio-app .studio-document-status-bar .studio-editor-unplaced-issues { flex: 1 1 100%; }
.studio-app .studio-asset-panel { margin-top: 28px; padding-top: 28px; border-top: 1px solid var(--line); } .studio-app .studio-asset-panel { margin-top: 28px; padding-top: 28px; border-top: 1px solid var(--line); }
.studio-app .studio-asset-panel .studio-eyebrow { margin-bottom: 8px; } .studio-app .studio-asset-panel .studio-eyebrow { margin-bottom: 8px; }
@@ -82,7 +140,7 @@
matches this form's submit button, and both selectors carry the same matches this form's submit button, and both selectors carry the same
specificity, so only source order tells them apart. */ specificity, so only source order tells them apart. */
/* `minmax(0, 1fr)` and the row's `min-width: 0` are both load bearing, for the /* `minmax(0, 1fr)` and the row's `min-width: 0` are both load bearing, for the
reason `.studio-editor-layout` already needs the same pair: a grid track and reason `.studio-editor-split` already needs the same pair: a grid track and
a flex container both default to an automatic minimum of their min-content a flex container both default to an automatic minimum of their min-content
size, so at 360px the search row sized itself to 366px inside a 328px column size, so at 360px the search row sized itself to 366px inside a 328px column
and pushed the submit button off-screen (document scrollWidth 382). */ and pushed the submit button off-screen (document scrollWidth 382). */
@@ -103,15 +161,19 @@
.studio-app .studio-preview-error ul { margin: 20px 0 0; padding-left: 20px; color: #8f2f27; line-height: 1.7; overflow-wrap: anywhere; } .studio-app .studio-preview-error ul { margin: 20px 0 0; padding-left: 20px; color: #8f2f27; line-height: 1.7; overflow-wrap: anywhere; }
@media (max-width: 1024px) { @media (max-width: 1024px) {
.studio-app .studio-editor-layout { grid-template-columns: minmax(0, 1fr); gap: 40px; } .studio-app .studio-editor-split { grid-template-columns: minmax(0, 1fr); gap: 40px; }
.studio-app .studio-document-status-rail { position: static; } .studio-app .studio-editor-split .studio-editor-heading h1 { font-size: clamp(38px, 5vw, 62px); }
.studio-app .studio-editor-preview { padding-top: 8px; padding-left: 0; border-top: 1px solid var(--line-strong); border-left: 0; }
} }
@media (max-width: 767px) { @media (max-width: 767px) {
.studio-app .studio-editor-heading { padding-bottom: 28px; } .studio-app .studio-editor-heading { padding-bottom: 28px; }
.studio-app .studio-editor-tabs { gap: 18px; } .studio-app .studio-editor-split { padding-top: 28px; }
.studio-app .studio-editor-tabs button { flex: 1; min-width: 0; } /* 안내문은 줄로 접혀 뷰포트의 20% 고정으로 가져간다. 실패 문구는 남기고 안내만 접는다
.studio-app .studio-editor-layout { padding-top: 28px; } Ctrl+S 여기서 누를 있는 것도 아니다. */
.studio-app .studio-document-status-bar__note { display: none; }
.studio-app .studio-document-status-bar__actions { flex: 1 1 100%; margin-left: 0; }
.studio-app .studio-document-status-bar__actions button { flex: 1; min-width: 0; }
.studio-app .studio-field-grid, .studio-app .studio-field-grid,
.studio-app .studio-resolution-fields { grid-template-columns: minmax(0, 1fr); } .studio-app .studio-resolution-fields { grid-template-columns: minmax(0, 1fr); }
.studio-app .studio-field--wide, .studio-app .studio-field--wide,
@@ -156,6 +218,6 @@
저장과 게시는 되돌릴 있는 정도가 다르다 하나는 초안을 남기고, 하나는 공개한다. 둘이 저장과 게시는 되돌릴 있는 정도가 다르다 하나는 초안을 남기고, 하나는 공개한다. 둘이
맞붙어 있으면 누르려던 것을 지나쳐 누르기 쉬우므로 사이를 벌린다. 맞붙어 있으면 누르려던 것을 지나쳐 누르기 쉬우므로 사이를 벌린다.
*/ */
.studio-app .studio-document-status-rail button + button { margin-top: 10px; } .studio-app .studio-document-status-bar__actions { gap: 12px; }
/* 게시만 강조한다. 저장은 되돌릴 수 있으므로 같은 무게로 부를 이유가 없다. */ /* 게시만 강조한다. 저장은 되돌릴 수 있으므로 같은 무게로 부를 이유가 없다. */
.studio-app .studio-document-status-rail button:not(.studio-primary-button) { border-color: var(--line-strong); background: var(--paper); color: var(--ink); } .studio-app .studio-document-status-bar button:not(.studio-primary-button) { border-color: var(--line-strong); background: var(--paper); color: var(--ink); }
@@ -107,6 +107,15 @@
.studio-app .studio-document-row dt { color: var(--muted); font-size: 11px; } .studio-app .studio-document-row dt { color: var(--muted); font-size: 11px; }
.studio-app .studio-document-row dd { margin: 5px 0 0; font-size: 13px; overflow-wrap: anywhere; } .studio-app .studio-document-row dd { margin: 5px 0 0; font-size: 13px; overflow-wrap: anywhere; }
.studio-app .studio-secondary-button { margin-top: 24px; } .studio-app .studio-secondary-button { margin-top: 24px; }
/*
행에 버튼이 이상일 . `.studio-secondary-button` 위쪽 여백은 버튼이 하나뿐이던 때의
값이라, 묶음 안에서는 묶음이 여백을 갖고 버튼은 나란히 선다.
*/
.studio-app .studio-row-actions { display: flex; flex-wrap: wrap; gap: 10px; margin-top: 24px; }
.studio-app .studio-field-note { margin: 6px 0 0; color: var(--muted); font-size: 12px; line-height: 1.6; }
.studio-app .studio-row-actions .studio-primary-button,
.studio-app .studio-row-actions .studio-secondary-button { margin: 0; }
.studio-app .studio-empty-state { padding-block: 56px; border-bottom: 1px solid var(--line); } .studio-app .studio-empty-state { padding-block: 56px; border-bottom: 1px solid var(--line); }
.studio-app .studio-empty-state h2 { margin: 0; font-size: 25px; } .studio-app .studio-empty-state h2 { margin: 0; font-size: 25px; }
.studio-app .studio-empty-state p { margin: 12px 0 22px; color: var(--muted); } .studio-app .studio-empty-state p { margin: 12px 0 22px; color: var(--muted); }
@@ -118,8 +127,14 @@
.studio-app .studio-type-list strong { font-size: 18px; } .studio-app .studio-type-list strong { font-size: 18px; }
.studio-app .studio-type-list span, .studio-app .studio-type-list span,
.studio-app .studio-type-list small { color: var(--muted); line-height: 1.6; } .studio-app .studio-type-list small { color: var(--muted); line-height: 1.6; }
.studio-app .studio-create-footer { display: flex; align-items: center; gap: 20px; margin-top: 28px; } .studio-app .studio-create-footer { display: flex; flex-wrap: wrap; align-items: center; gap: 12px 20px; margin-top: 28px; }
.studio-app .studio-create-footer .studio-primary-button { margin: 0; } /*
`.studio-secondary-button` 목록 행에서 쓰려고 전역으로 `margin-top: 24px` 갖는다. 여기서
풀어 주지 않으면 primary(저장) 위로 붙고 secondary(공개·공개에서 내리기) 24px 아래로
내려앉아, 같은 줄의 버튼들이 서로 다른 높이에 선다.
*/
.studio-app .studio-create-footer .studio-primary-button,
.studio-app .studio-create-footer .studio-secondary-button { margin: 0; }
.studio-app .studio-create-footer p { margin: 0; color: var(--muted); font-size: 13px; } .studio-app .studio-create-footer p { margin: 0; color: var(--muted); font-size: 13px; }
.studio-app .studio-primary-button:disabled { opacity: 0.55; cursor: wait; } .studio-app .studio-primary-button:disabled { opacity: 0.55; cursor: wait; }
@@ -216,6 +216,13 @@ export const TECH_LOG_ROUTE_RUNTIME = Object.freeze({
"TaxonomyPage", "TaxonomyPage",
), ),
), ),
TECH_LOG_STUDIO_PROJECT_EDIT: runtime(
"TECH_LOG_STUDIO_PROJECT_EDIT",
routeModule(
() => import("./studio/pages/project-edit-page.tsx"),
"ProjectEditPage",
),
),
TECH_LOG_STUDIO_RELEASES: runtime( TECH_LOG_STUDIO_RELEASES: runtime(
"TECH_LOG_STUDIO_RELEASES", "TECH_LOG_STUDIO_RELEASES",
routeModule( routeModule(
@@ -223,6 +230,13 @@ export const TECH_LOG_ROUTE_RUNTIME = Object.freeze({
"StudioReleasesPage", "StudioReleasesPage",
), ),
), ),
TECH_LOG_STUDIO_RELEASE_EDIT: runtime(
"TECH_LOG_STUDIO_RELEASE_EDIT",
routeModule(
() => import("./studio/pages/release-edit-page.tsx"),
"ReleaseEditPage",
),
),
TECH_LOG_STUDIO_NOT_FOUND: runtime( TECH_LOG_STUDIO_NOT_FOUND: runtime(
"TECH_LOG_STUDIO_NOT_FOUND", "TECH_LOG_STUDIO_NOT_FOUND",
routeModule( routeModule(
+13 -6
View File
@@ -87,11 +87,16 @@ const PLATFORM_KO_MESSAGES = {
"route.auth.integration.description": "route.auth.integration.description":
"외부 인증 소유자가 연결되면 이 보호 라우트를 사용할 수 있습니다.", "외부 인증 소유자가 연결되면 이 보호 라우트를 사용할 수 있습니다.",
"route.auth.recovering.title": "세션을 복구하고 있습니다.", "route.auth.recovering.title": "세션을 복구하고 있습니다.",
"route.auth.required.title": "세션이 필요합니다.", "route.auth.eyebrow": "SIGN IN",
"route.auth.returnTo": "로그인하면 {path} 로 돌아옵니다.",
"route.auth.required.title": "Studio에 로그인해 주세요.",
"route.auth.recovering.description": "route.auth.recovering.description":
"기존 세션 확인을 계속하려면 복구를 실행하세요.", "이전 로그인이 아직 살아 있는지 확인하고 있습니다. 잠시 뒤에도 이 화면이면 다시 시도해 주세요.",
// 예전 문구("인증 연동 지점을 확인하기 위한 보호 라우트")는 이 뼈대를 만들던 사람에게 하는
// 말이었다. 실제로 이 화면을 보는 사람은 글을 쓰러 온 작성자이고, 알아야 할 것은 무엇을
// 누르면 되는지다.
"route.auth.required.description": "route.auth.required.description":
"이 화면은 인증 연동 지점을 확인하기 위한 보호 라우트입니다.", "기록을 쓰고 게시하려면 로그인이 필요합니다. 로그인하면 방금 열려던 화면으로 돌아옵니다.",
"route.documentTitle": "{title} · {appName}", "route.documentTitle": "{title} · {appName}",
"chunk.checking": "새 릴리스 정보를 확인하고 있습니다.", "chunk.checking": "새 릴리스 정보를 확인하고 있습니다.",
"chunk.reloadOnce": "새 버전으로 한 번만 전환합니다.", "chunk.reloadOnce": "새 버전으로 한 번만 전환합니다.",
@@ -271,11 +276,13 @@ const PLATFORM_EN_MESSAGES = {
"route.auth.integration.description": "route.auth.integration.description":
"This protected route is available after an external authentication owner is connected.", "This protected route is available after an external authentication owner is connected.",
"route.auth.recovering.title": "Recovering the session.", "route.auth.recovering.title": "Recovering the session.",
"route.auth.required.title": "A session is required.", "route.auth.eyebrow": "SIGN IN",
"route.auth.returnTo": "You return to {path} after signing in.",
"route.auth.required.title": "Sign in to Studio.",
"route.auth.recovering.description": "route.auth.recovering.description":
"Run recovery to continue checking the existing session.", "Checking whether the previous sign-in is still valid. Try again if this screen stays.",
"route.auth.required.description": "route.auth.required.description":
"This protected route demonstrates the authentication integration seam.", "Writing and publishing records needs a signed-in session. You return to this screen afterwards.",
"route.documentTitle": "{title} · {appName}", "route.documentTitle": "{title} · {appName}",
"chunk.checking": "Checking the new release information.", "chunk.checking": "Checking the new release information.",
"chunk.reloadOnce": "Switching to the new version once.", "chunk.reloadOnce": "Switching to the new version once.",
@@ -19,6 +19,7 @@ export type MessageParameters = Readonly<{
"action.alertCloseNamed": { title: string }; "action.alertCloseNamed": { title: string };
"route.loadingNamed": { title: string }; "route.loadingNamed": { title: string };
"route.documentTitle": { title: string; appName: string }; "route.documentTitle": { title: string; appName: string };
"route.auth.returnTo": { path: string };
"template.supportReference": { reference: string }; "template.supportReference": { reference: string };
"form.remaining": { count: number }; "form.remaining": { count: number };
"boot.supportReference": { reference: string }; "boot.supportReference": { reference: string };
+18 -5
View File
@@ -241,9 +241,17 @@ function ProtectedRoute({
); );
} }
const recovering = decision.action === "wait-for-session"; const recovering = decision.action === "wait-for-session";
/*
Signing in is a screen, not a bare gate. This used to be a title and a button
dropped on `ui-page`, so opening a protected address showed no page at all
just two stray elements. It is one card now, and it names the address the
visitor came for so they know where signing in returns them.
*/
return ( return (
<section className="ui-page" data-surface="authentication-required"> <section className="ui-page auth-gate" data-surface="authentication-required">
<div className="auth-gate__card">
<PageHeader <PageHeader
eyebrow={message("route.auth.eyebrow")}
title={ title={
recovering recovering
? message("route.auth.recovering.title") ? message("route.auth.recovering.title")
@@ -255,21 +263,26 @@ function ProtectedRoute({
: message("route.auth.required.description") : message("route.auth.required.description")
} }
/> />
<Button <div className="auth-gate__actions">
disabled={pending} <Button disabled={pending} onClick={() => void continueSession()}>
onClick={() => void continueSession()}
>
{pending {pending
? message("common.processing") ? message("common.processing")
: recovering : recovering
? message("action.recoverSession") ? message("action.recoverSession")
: message("action.signIn")} : message("action.signIn")}
</Button> </Button>
<p className="auth-gate__return">
{message("route.auth.returnTo", {
path: `${location.pathname}${location.search}`,
})}
</p>
</div>
{failed ? ( {failed ? (
<p className="ui-terminal-error" role="alert"> <p className="ui-terminal-error" role="alert">
{message("shell.session.actionFailed")} {message("shell.session.actionFailed")}
</p> </p>
) : null} ) : null}
</div>
</section> </section>
); );
} }
@@ -461,8 +461,7 @@ test("inserts the directive at the saved cursor position and the live preview re
`${body.slice(0, cursor)}\n\n${expectedDirective}${body.slice(cursor)}`, `${body.slice(0, cursor)}\n\n${expectedDirective}${body.slice(cursor)}`,
); );
await user.click(screen.getByRole("tab", { name: "즉시 미리보기" })); const panel = screen.getByRole("region", { name: "즉시 미리보기" });
const panel = screen.getByRole("tabpanel", { name: "즉시 미리보기" });
expect(within(panel).queryByRole("alert")).not.toBeInTheDocument(); expect(within(panel).queryByRole("alert")).not.toBeInTheDocument();
// `zoom: true` (DIAGRAM kind) renders the figure's image twice -- once as // `zoom: true` (DIAGRAM kind) renders the figure's image twice -- once as
@@ -1819,12 +1818,12 @@ test("a Picker search never drops an already-inserted asset out of Instant Previ
// must narrow; the preview must not. // must narrow; the preview must not.
await user.type(screen.getByLabelText("Asset 검색"), "other"); await user.type(screen.getByLabelText("Asset 검색"), "other");
await user.click(screen.getByRole("button", { name: "검색" })); await user.click(screen.getByRole("button", { name: "검색" }));
const picker = screen.getByRole("group", { name: "본문에 Asset 삽입" });
await waitFor(() => await waitFor(() =>
expect(screen.queryByRole("button", { name: /inserted-diagram/ })).not.toBeInTheDocument(), expect(within(picker).queryByRole("button", { name: /inserted-diagram/ })).not.toBeInTheDocument(),
); );
await user.click(screen.getByRole("tab", { name: "즉시 미리보기" })); const panel = screen.getByRole("region", { name: "즉시 미리보기" });
const panel = screen.getByRole("tabpanel", { name: "즉시 미리보기" });
assert.equal( assert.equal(
within(panel).queryByRole("alert")?.textContent ?? null, within(panel).queryByRole("alert")?.textContent ?? null,
+32 -1
View File
@@ -60,6 +60,36 @@ describe("Content Format v1", () => {
]); ]);
}); });
it("accepts the plain Markdown the server renderer already accepted", () => {
/*
Studio
. `:::table` , callout
note/tip/warning/danger .
*/
expect(parseCaseContent("| A | B |\n|---|---|\n| 1 | 2 |").map((block) => block.type)).toEqual([
"DATA_TABLE",
]);
expect(parseCaseContent(":::note\n\n참고할 것.\n\n:::").map((block) => block.type)).toEqual([
"CALLOUT",
]);
expect(parseCaseContent(":::danger\n\n위험.\n\n:::").map((block) => block.type)).toEqual([
"CALLOUT",
]);
expect(parseCaseContent("# 제목1\n\n###### 제목6").map((block) => block.type)).toEqual([
"HEADING",
"HEADING",
]);
expect(parseCaseContent("---").map((block) => block.type)).toEqual(["THEMATIC_BREAK"]);
expect(parseCaseContent("![대체](/api/v1/public/media/x)").map((block) => block.type)).toEqual([
"IMAGE",
]);
// 속성이 없는 directive 도 이름이 온전해야 한다. 정규화 정규식이 되돌아가며 마지막 글자를
// 속성 쪽으로 넘기는 바람에 `:::note` 가 `:::not{e}` 로 바뀌던 적이 있다.
expect(() => parseCaseContent(":::unknown\n\ntext\n\n:::")).toThrow(
/unknown block directive: unknown/,
);
});
it("rejects unsafe, raw HTML, and unsupported input with source positions", () => { it("rejects unsafe, raw HTML, and unsupported input with source positions", () => {
const rejected = [ const rejected = [
"<script>alert(1)</script>", "<script>alert(1)</script>",
@@ -74,11 +104,12 @@ describe("Content Format v1", () => {
"[x](</safe\u001fpath>)", "[x](</safe\u001fpath>)",
"[x](</safe\u007fpath>)", "[x](</safe\u007fpath>)",
"- outer\n - nested", "- outer\n - nested",
"# level one",
"- [ ] task", "- [ ] task",
"> > nested", "> > nested",
':::unknown key="value"\ntext\n:::', ':::unknown key="value"\ntext\n:::',
':::evidence key="https://example.com/x.png" alt="x" caption="x" zoom="true"\n:::', ':::evidence key="https://example.com/x.png" alt="x" caption="x" zoom="true"\n:::',
"![x](javascript:alert(1))",
"![x](//evil.example/x.png)",
]; ];
for (const source of rejected) { for (const source of rejected) {
@@ -18,7 +18,7 @@ test("vendored contract matches the recorded canonical digest", () => {
test("canonical source records the pinned revision and version", () => { test("canonical source records the pinned revision and version", () => {
assert.equal(canonicalSource.packageId, "@tech-log/studio-contract"); assert.equal(canonicalSource.packageId, "@tech-log/studio-contract");
assert.equal(canonicalSource.version, "3.0.0"); assert.equal(canonicalSource.version, "3.1.0");
// revision은 생성 시점에 기록된다. canonical 저장소는 활발히 편집 중이므로 // revision은 생성 시점에 기록된다. canonical 저장소는 활발히 편집 중이므로
// 특정 값을 박아두면 계약이 그대로인데도 테스트가 깨진다. 형식만 고정한다. // 특정 값을 박아두면 계약이 그대로인데도 테스트가 깨진다. 형식만 고정한다.
assert.match(canonicalSource.sourceRevision, /^[0-9a-f]{7,64}$/); assert.match(canonicalSource.sourceRevision, /^[0-9a-f]{7,64}$/);
@@ -296,7 +296,7 @@ describe("TechLog explore discovery", () => {
const user = userEvent.setup(); const user = userEvent.setup();
const { router } = await renderDiscoveryRoute( const { router } = await renderDiscoveryRoute(
"TECH_LOG_EXPLORE", "TECH_LOG_EXPLORE",
"/explore?type=CASE&topic=JPA&project=backend-skeleton", "/explore?type=CASE&topic=jpa&project=backend-skeleton",
); );
expect(screen.getByRole("heading", { level: 1, name: "탐색" })).toBeVisible(); expect(screen.getByRole("heading", { level: 1, name: "탐색" })).toBeVisible();
@@ -306,22 +306,28 @@ describe("TechLog explore discovery", () => {
// 필터의 선택지는 카탈로그가 도착한 뒤 채워지고, select 의 값도 그때 설정된다. // 필터의 선택지는 카탈로그가 도착한 뒤 채워지고, select 의 값도 그때 설정된다.
await waitFor(() => { await waitFor(() => {
expect(screen.getByLabelText("유형")).toHaveValue("CASE"); expect(screen.getByLabelText("유형")).toHaveValue("CASE");
expect(screen.getByLabelText("주제")).toHaveValue("JPA"); expect(screen.getByLabelText("주제")).toHaveValue("jpa");
expect(screen.getByLabelText("프로젝트")).toHaveValue("backend-skeleton"); expect(screen.getByLabelText("프로젝트")).toHaveValue("backend-skeleton");
}); });
// 주제 선택지는 보이는 이름과 보내는 값이 다르다. 값이 이름이면 slug 로 거르는 API 가
// 0건을 돌려주고, 화면은 「조건에 맞는 공개 기록이 없습니다」만 남는다 — 운영에서 실제로
// 그랬다. 목록에서 유도한 값이라 이 단언이 없으면 조용히 되돌아간다.
expect(
within(screen.getByLabelText("주제")).getByRole("option", { name: "Authentication" }),
).toHaveValue("authentication");
expect(screen.getByText("1개의 공개 기록")).toBeVisible(); expect(screen.getByText("1개의 공개 기록")).toBeVisible();
expect( expect(
screen.getByRole("link", { name: /컬렉션 Fetch Join과 페이징은 왜 충돌하는가/ }), screen.getByRole("link", { name: /컬렉션 Fetch Join과 페이징은 왜 충돌하는가/ }),
).toHaveAttribute("href", "/cases/collection-fetch-join-pagination"); ).toHaveAttribute("href", "/cases/collection-fetch-join-pagination");
await user.selectOptions(screen.getByLabelText("유형"), "QUESTION"); await user.selectOptions(screen.getByLabelText("유형"), "QUESTION");
await user.selectOptions(screen.getByLabelText("주제"), "Authentication"); await user.selectOptions(screen.getByLabelText("주제"), "authentication");
await user.selectOptions(screen.getByLabelText("프로젝트"), "auth-lab"); await user.selectOptions(screen.getByLabelText("프로젝트"), "auth-lab");
await user.click(screen.getByRole("button", { name: "적용" })); await user.click(screen.getByRole("button", { name: "적용" }));
await waitFor(() => { await waitFor(() => {
expect(router.state.location.search).toBe( expect(router.state.location.search).toBe(
"?project=auth-lab&topic=Authentication&type=QUESTION", "?project=auth-lab&topic=authentication&type=QUESTION",
); );
}); });
expect(screen.getByText("1개의 공개 기록")).toBeVisible(); expect(screen.getByText("1개의 공개 기록")).toBeVisible();
@@ -42,6 +42,13 @@ const routeComponents = {
type DocumentRouteId = keyof typeof routeComponents; type DocumentRouteId = keyof typeof routeComponents;
/** 머리말이 거는 주제 링크를 확인하기 위한 픽스처의 이름 → slug 대응. */
const topicSlugs: Readonly<Record<string, string>> = {
JPA: "jpa",
Authentication: "authentication",
Redis: "redis",
};
class NoopIntersectionObserver implements IntersectionObserver { class NoopIntersectionObserver implements IntersectionObserver {
readonly root = null; readonly root = null;
readonly rootMargin = "0px"; readonly rootMargin = "0px";
@@ -205,13 +212,30 @@ describe("TechLog canonical Public documents", () => {
const main = screen.getByRole("main"); const main = screen.getByRole("main");
expect(within(main).getByRole("heading", { level: 1, name: title })).toBeVisible(); expect(within(main).getByRole("heading", { level: 1, name: title })).toBeVisible();
expect(within(main).getByText(evidence, { exact: false })).toBeVisible(); /*
expect(within(main).getByRole("navigation", { name: "문서 경로" })).toHaveTextContent( .
`${kind}/${topic}/${project}`, "그 글이 문서에 있는가" .
*/
const [firstEvidence] = within(main).getAllByText(evidence, { exact: false });
expect(firstEvidence).toBeVisible();
const breadcrumb = within(main).getByRole("navigation", { name: "문서 경로" });
expect(breadcrumb).toHaveTextContent(`${kind}/${topic}/${project}`);
/*
.
`/topics/:slug` ,
404 .
*/
expect(within(breadcrumb).getByRole("link", { name: topic })).toHaveAttribute(
"href",
`/explore?topic=${topicSlugs[topic]}`,
); );
if (path === "/cases/collection-fetch-join-pagination") { /*
expect(within(main).getByText(`게시 ${published} · 마지막 검증 2026.08.11`)).toBeVisible(); Case . `.case-meta` Case
`.public-document-header dl` . .
*/
if (kind === "Case") {
expect(container.querySelector(".case-meta")).toHaveTextContent(`게시 ${published}`);
} else { } else {
const metadata = container.querySelector(".public-document-header dl"); const metadata = container.querySelector(".public-document-header dl");
expect(metadata).toHaveTextContent(`유형${kind}`); expect(metadata).toHaveTextContent(`유형${kind}`);
@@ -258,12 +282,12 @@ describe("TechLog canonical Public documents", () => {
expect(image).toHaveAttribute("loading", "lazy"); expect(image).toHaveAttribute("loading", "lazy");
}); });
it("uses the generic Case markup and omits source relations for the canonical empty state", async () => { it("uses the one Case layout and omits source relations for the canonical empty state", async () => {
const generic = await renderDocumentRoute( const generic = await renderDocumentRoute(
"TECH_LOG_CASE", "TECH_LOG_CASE",
"/cases/redis-adapter-ttl-boundary", "/cases/redis-adapter-ttl-boundary",
); );
expect(screen.getByRole("main")).toHaveClass("shell", "public-document-page"); expect(screen.getByRole("main")).toHaveClass("case-page");
expect(generic.container.querySelector("#ownership")).toHaveTextContent( expect(generic.container.querySelector("#ownership")).toHaveTextContent(
"정책과 저장 명령의 주인을 구분하기", "정책과 저장 명령의 주인을 구분하기",
); );
@@ -252,35 +252,26 @@ describe("TechLog project screens", () => {
]); ]);
}); });
it("keeps project activity ordered and preserves self-fragment and record links", async () => { /*
.
, "기록" "활동" .
.
*/
it("keeps project activity ordered and leaves reading to the records screen", async () => {
const { container } = await renderPublicRoute( const { container } = await renderPublicRoute(
"TECH_LOG_PROJECT_ACTIVITY", "TECH_LOG_PROJECT_ACTIVITY",
"/projects/backend-skeleton/activity", "/projects/backend-skeleton/activity",
); );
expect( const items = Array.from(
Array.from(container.querySelectorAll(".project-activity-list > li > article"), (item) => ({ container.querySelectorAll(".project-activity-list > li > article"),
id: item.id, );
href: item.querySelector("a")?.getAttribute("href"), expect(items.map((item) => item.id)).toEqual([
label: item.querySelector("a")?.textContent, "fetch-join-case-published",
})), "storage-contract",
).toEqual([ "redis-case-published",
{
id: "fetch-join-case-published",
href: "/cases/collection-fetch-join-pagination",
label: "연결된 공개 기록 읽기",
},
{
id: "storage-contract",
href: "/projects/backend-skeleton/activity#storage-contract",
label: "이 활동 위치 열기",
},
{
id: "redis-case-published",
href: "/cases/redis-adapter-ttl-boundary",
label: "연결된 공개 기록 읽기",
},
]); ]);
expect(items.flatMap((item) => Array.from(item.querySelectorAll("a")))).toEqual([]);
}); });
}); });
@@ -0,0 +1,131 @@
import { strict as assert } from "node:assert";
import { test } from "vitest";
import type { components } from "../../../src/features/tech-log/contracts/public/generated.ts";
import { createHttpPublicContentGateway } from "../../../src/features/tech-log/adapters/http/http-public-content-gateway.ts";
type QuestionDetail = components["schemas"]["QuestionDetailResponse"];
/*
Open Question .
`points` `{group, items}` `.filter` ,
`QuestionPointGroup` ****. `.filter`
. `as` .
"게시했는데 public 에 안 뜬다" , .
,
. .
*/
function gatewayReturning(value: unknown) {
return createHttpPublicContentGateway({
operations: {
execute: () =>
Promise.resolve({
kind: "SUCCESS" as const,
value,
metadata: { status: 200 },
effect: "NOT_APPLICABLE" as const,
}),
},
});
}
const DETAIL = {
canonicalPath: "/questions/refresh-rotation-replica-contention",
indexable: true,
question: {
question: "Refresh Token Rotation과 다중 Replica 경쟁을 어떻게 처리할 것인가",
summary: "두 replica가 같은 refresh token으로 동시에 갱신할 수 있다.",
context: "",
importance: "",
status: "OPEN",
nextVerification: "저장소를 공유한 뒤에 재현한다.",
points: {
facts: ["realm은 refresh token rotation과 재사용 허용 0회를 쓴다."],
assumptions: ["운영에서는 replica가 둘 이상이고 저장소를 공유한다."],
unknowns: ["같은 refresh token으로 동시에 갱신하면 어떻게 되는지."],
constraints: ["이미 발급된 access token은 만료 전까지 계속 통한다."],
},
updates: [],
openedAt: "2026-08-24T00:00:00.000Z",
updatedAt: "2026-08-26T13:41:53.974512Z",
},
relations: {
primaryProject: {
type: "PROJECT",
title: "KeyCloak Patterns",
path: "/projects/keycloak-patterns",
summary: "Keycloak을 쓰면서 실제로 부딪힌 인증 경계를 기록합니다",
},
resultCase: {
type: "CASE",
title: "Refresh Token 관리만 서버로 이전, Access Token은 여전히 Browser에 노출",
path: "/cases/split-custody-access-token",
summary: "토큰 교환과 토큰 관리의 책임이 서버로 이전했다.",
},
derivedReferences: [],
},
} satisfies QuestionDetail;
test("a published Open Question maps its four point groups into the screen's fields", async () => {
const gateway = gatewayReturning(DETAIL);
const record = await gateway.getRecord("QUESTION", "refresh-rotation-replica-contention");
assert.ok(record, "게시된 질문은 상세로 돌아와야 한다");
assert.equal(record.kind, "QUESTION");
assert.equal(record.title, DETAIL.question.question);
assert.equal(record.questionStatus, "OPEN");
assert.deepEqual([...record.facts], DETAIL.question.points.facts);
assert.deepEqual([...record.assumptions], DETAIL.question.points.assumptions);
assert.deepEqual([...record.unknowns], DETAIL.question.points.unknowns);
assert.deepEqual([...record.constraints], DETAIL.question.points.constraints);
assert.equal(record.nextValidation, DETAIL.question.nextVerification);
// 질문 상세는 프로젝트를 `relations` 에 담는다 — `question` 에서 찾으면 머리말의 프로젝트
// 칸이 늘 비어 있다.
assert.equal(record.projectTitle, "KeyCloak Patterns");
assert.equal(record.projectSlug, "keycloak-patterns");
});
/*
. `primaryProject` .
*/
test("a question's relations read their reason from the contract's own group names", async () => {
const gateway = gatewayReturning(DETAIL);
const record = await gateway.getRecord("QUESTION", "refresh-rotation-replica-contention");
assert.deepEqual(
[...record!.relations].map((relation) => ({ reason: relation.reason, path: relation.path })),
[
{
reason: "이 질문에서 나온 기록",
path: "/cases/split-custody-access-token",
},
],
);
});
/*
. `satisfies` ,
.
*/
test("the contract names the four point groups the screen renders", () => {
const points: components["schemas"]["QuestionPointGroup"] = {
facts: [],
assumptions: [],
unknowns: [],
constraints: [],
};
assert.deepEqual(Object.keys(points).sort(), [
"assumptions",
"constraints",
"facts",
"unknowns",
]);
});
@@ -0,0 +1,71 @@
import { strict as assert } from "node:assert";
import { test } from "vitest";
import type { components } from "../../../src/features/tech-log/contracts/public/generated.ts";
type ReferenceBody =
components["schemas"]["ReferenceDetailResponse"]["reference"];
/*
.
Reference . Studio "
" .
`purposeSummary`, `applyWhenMarkdown`, `exceptionsMarkdown`, `examplesMarkdown`. undefined
`as string` .
****. ,
.
*/
test("the public Reference contract keeps the fields the screen reads", () => {
const reference = {
title: "",
// 제목 바로 아래에 오는 것은 문서의 요약이다. scopeSummary 는 그 아래 "이 기준을 쓰는 이유"다.
summary: "",
scopeSummary: "",
appliesTo: [],
excludedScope: [],
rules: [{ title: "", body: "" }],
examples: [],
freshnessStatus: "CURRENT",
content: "",
contentFormat: "MARKDOWN",
contentFormatVersion: 1,
tags: [],
publishedAt: "",
updatedAt: "",
} satisfies ReferenceBody;
// 화면이 읽는 이름 그대로. 하나라도 계약에서 사라지면 위의 `satisfies` 가 먼저 깨진다.
assert.deepEqual(Object.keys(reference).sort(), [
"appliesTo",
"content",
"contentFormat",
"contentFormatVersion",
"examples",
"excludedScope",
"freshnessStatus",
"publishedAt",
"rules",
"scopeSummary",
"summary",
"tags",
"title",
"updatedAt",
]);
});
/*
Reference `content` . Studio
`body_markdown` `content`
.
*/
test("a rule carries its own title and body, not a slice of markdown", () => {
const rule: NonNullable<ReferenceBody["rules"]>[number] = {
title: "Authorization Endpoint에는 client_secret을 보내지 않는다",
body: "이 요청은 브라우저의 full-page navigation으로 나간다.",
};
assert.equal(typeof rule.title, "string");
assert.equal(typeof rule.body, "string");
});
+11 -3
View File
@@ -386,7 +386,12 @@ describe("shared Public record renderer", () => {
); );
}); });
it("uses generic Case markup and does not present generatedAt as publication", () => { /*
Case "축약본" .
, breadcrumb Case
. .
*/
it("uses the one Case layout and does not present generatedAt as publication", () => {
renderInRouter( renderInRouter(
<PublicRecordRenderer <PublicRecordRenderer
{...renderDependencies} {...renderDependencies}
@@ -402,14 +407,17 @@ describe("shared Public record renderer", () => {
); );
const main = screen.getByRole("main"); const main = screen.getByRole("main");
expect(main).toHaveClass("shell", "public-document-page"); expect(main).toHaveClass("case-page");
expect(main).toHaveAttribute("id", "main-content"); expect(main).toHaveAttribute("id", "main-content");
expect(screen.getByText("게시 전")).toBeVisible(); // 완성된 배치에서는 "게시 전" 이 기록 줄의 한 문장 안에 들어간다 — 홀로 선 노드가 아니다.
expect(main).toHaveTextContent("게시 전");
expect(main).not.toHaveTextContent("2035.05.06"); expect(main).not.toHaveTextContent("2035.05.06");
expect(screen.getByRole("region", { name: "문제와 결론" })).toHaveTextContent( expect(screen.getByRole("region", { name: "문제와 결론" })).toHaveTextContent(
"문제문제결론결론", "문제문제결론결론",
); );
expect(screen.getByText("일반 본문")).toBeVisible(); expect(screen.getByText("일반 본문")).toBeVisible();
// 제목이 없는 본문에는 목차를 그리지 않는다 — 빈 레일만 남는다.
expect(screen.queryByLabelText("문서 목차")).toBeNull();
}); });
it("renders supplied Reference and Question preview fields and empty copy", () => { it("renders supplied Reference and Question preview fields and empty copy", () => {

Some files were not shown because too many files have changed in this diff Show More