Compare commits

...
10 Commits
Author SHA1 Message Date
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
88 changed files with 2747 additions and 1705 deletions
+1
View File
@@ -29,3 +29,4 @@ artifacts/tests/visual/
# Git worktrees created inside the repository. A worktree is a checkout, not
# source: committing one would nest a second working copy inside this one.
.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",
"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",
"path": "artifacts/tests/a11y-manual/TECH_LOG_STUDIO_RELEASES.md",
"schemaId": "markdown",
"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",
"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-ASSETS-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-RELEASE-EDIT-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-report-json"
+2 -2
View File
@@ -5,8 +5,8 @@
"registrationAllowed": false,
"loginTheme": "keycloak",
"accessTokenLifespan": 300,
"ssoSessionIdleTimeout": 1800,
"ssoSessionMaxLifespan": 36000,
"ssoSessionIdleTimeout": 28800,
"ssoSessionMaxLifespan": 86400,
"roles": {
"realm": [
{ "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']",
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": {
"setAlgorithm": "CA_CONTRACT_SET_V1",
"setDigest": "sha256:7ee35548d2a84b744f8c17c7b785a79b328dcf756f5c9df8d77d37ff1535b087",
"setDigest": "sha256:b729150c56b64ba01d7a71a7442c14bb04d9e573b852d1d1879e748854c20174",
"packages": [
{
"packageId": "@tech-log/management-contract",
"version": "1.0.0",
"digest": "sha256:d19ae7c4fbcac924a356bbb0cc1a46a4046ecec701158ca0a9b7cc089bbaf878",
"digest": "sha256:72650735061fde627f5037571eb986cb758f44a546f065c88408399f8eec4a55",
"runtimeProtocolVersion": 1,
"sourceRevision": "b195b29"
"sourceRevision": "06ae075"
},
{
"packageId": "@tech-log/public-contract",
"version": "2.1.0",
"digest": "sha256:37e6f804165de3e492e975075bea563ee41ae74076222a3562d3452631bfdb2b",
"digest": "sha256:702d6666a8feba9899c7eb7c2a94a0880bcb23b178c7ed2009c6e69d9a1c848c",
"runtimeProtocolVersion": 1,
"sourceRevision": "b195b29"
"sourceRevision": "06ae075"
},
{
"packageId": "@tech-log/studio-contract",
"version": "3.0.0",
"digest": "sha256:674327a82951fd4a1bc2594c858072dfcd0b9abe7c63198283da5d8c92a04326",
"version": "3.1.0",
"digest": "sha256:6fc015ca6727af88b7fb0088e02ba97846e1dd79fb0d4fc593cc79f2a3b9795f",
"runtimeProtocolVersion": 1,
"sourceRevision": "b195b29"
"sourceRevision": "06ae075"
}
]
}
+13 -3
View File
@@ -468,7 +468,12 @@ const CANONICAL_GATE_SHAPE_SHA256 =
// TECH_LOG_STUDIO_RELEASES evidence artifact, by the same method — 8c73d447…
// 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.
"187dbd9676b7b3444409b5af4143d49927b1eacc67f57a099f4a3765bb64d865";
// 프로젝트 편집 화면: FE-GATE-009 가 TECH_LOG_STUDIO_PROJECT_EDIT 증거 아티팩트를
// 얻어 다시 계산했다. 같은 방법이다 — 187dbd96… 을 이전 gates.json 에서 먼저 재현해,
// 이 값을 만든 계산이 상수가 고정될 때 쓰인 그 계산임을 확인했다.
// 릴리즈 편집 화면: 같은 방법으로 다시 계산했다. f9e7e521… 을 이전 gates.json 에서 먼저
// 재현했다.
"fb138e7c51fdf969f755cd8ff32cf627f1750b966d212c33ee996c8c578db0e3";
function canonicalGateShapeSha256(gates: CiGateContract["gates"]): string {
const normalized = gates.map(
@@ -523,8 +528,13 @@ function canonicalAuthorityBaselineFailures(contract: CiGateContract): string[]
// Alignment follow-up, item 2 added the TechLog junit report.
// 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.
if (contract.artifacts.length !== 132) {
failures.push(`artifact authority baseline must contain exactly 132 artifacts; received ${contract.artifacts.length}`);
// The project edit route did the same: it is what finally lets a project carry
// 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) {
failures.push(`stage authority baseline must contain exactly 5 stages; received ${contract.stages.length}`);
@@ -1,5 +1,10 @@
import type {
CreateDraftResponse,
HomeFocusRequest,
HomeFocusResponse,
ProjectActivityRequest,
ProjectActivityResponse,
UpdateProjectActivityRequest,
ProjectEditResponse,
ProjectIndexPage,
ProjectUpdateRequest,
@@ -85,6 +90,28 @@ export function createHttpManagementGateway(
deleteRelease: async (id: string, expectedVersion: number) => {
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) =>
run<PublishResponse>("publishRelease", { id, expectedVersion }),
archiveRelease: (id: string, expectedVersion: number) =>
@@ -1,5 +1,6 @@
import type {
HomeFocusItem,
LatestRecordEntry,
ProjectActivity,
ProjectDecision,
Project,
@@ -320,6 +321,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[]> {
const home = await read<Readonly<{ focus?: Readonly<Record<string, never>> }>>(
"getPublicHome",
@@ -463,6 +491,7 @@ export function createHttpPublicContentGateway(
getProjectDecisions,
getProjectActivity,
getHomeFocusItems,
getLatestEntries,
searchPublicContent,
});
}
@@ -11,6 +11,7 @@ import {
type HomeFocusItem,
} from "./public-content.ts";
import type {
LatestRecordEntry,
PublicContentQueries,
PublicTopic,
} from "../../application/ports/public-content-queries.ts";
@@ -119,6 +120,43 @@ export function listTopics(): PublicTopic[] {
.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,
};
}),
);
const releaseEntries = releases.map((release) => ({
id: `release-${release.version}`,
entryType: "RELEASE" as const,
title: release.title,
summary: release.summary,
path: release.path,
publishedAt: release.publishedAt,
topic: "TechLog",
project: "TechLog",
}));
return [...activities, ...releaseEntries].sort((left, right) =>
right.publishedAt.localeCompare(left.publishedAt),
);
}
export function getHomeFocusItems(): HomeFocusItem[] {
const project = getProject("backend-skeleton");
const question = getRecord("QUESTION", "validate-edge-token-again");
@@ -264,6 +302,9 @@ export const publicContentQueries = Object.freeze({
async listTopics() {
return listTopics();
},
async getLatestEntries() {
return getLatestEntries();
},
async getHomeFocusItems() {
return getHomeFocusItems();
},
@@ -1,5 +1,10 @@
import type {
CreateDraftResponse,
HomeFocusRequest,
HomeFocusResponse,
ProjectActivityRequest,
ProjectActivityResponse,
UpdateProjectActivityRequest,
ProjectEditResponse,
ProjectIndexPage,
ProjectUpdateRequest,
@@ -27,6 +32,33 @@ export type ManagementGateway = Readonly<{
createProject(title: string): Promise<CreateDraftResponse>;
updateProject(id: string, body: ProjectUpdateRequest): Promise<ProjectEditResponse>;
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>;
getRelease(id: string): Promise<ReleaseEditResponse>;
createRelease(title: string): Promise<CreateDraftResponse>;
@@ -164,6 +164,24 @@ export type PublicTopic = {
recordCount: number;
};
/**
* 홈의 "최근 기록" 한 줄. 서버가 공개 투영에서 직접 고른다.
*
* 화면이 프로젝트를 하나씩 돌며 조립하던 때에는, 게시된 문서라도 그 문서가 매달린 프로젝트가
* 공개되어 있지 않으면 목록에서 통째로 빠졌다 — 실제로 게시한 Case 는 안 보이고 릴리스만
* 남았다. 무엇이 최근인지는 공개 투영 하나가 알고 있으므로 거기서 그대로 읽는다.
*/
export type LatestRecordEntry = {
id: string;
entryType: "CASE" | "REFERENCE" | "PROJECT_ACTIVITY" | "RELEASE";
title: string;
summary: string;
path: string;
publishedAt: string;
topic: string;
project: string;
};
export type FocusKey = "current" | "question" | "decision";
export type HomeFocusItem = {
@@ -227,6 +245,7 @@ export type PublicContentQueries = Readonly<{
* 있었고, Studio 에서 주제를 만들어도 바뀌지 않았다.
*/
listTopics(): Promise<PublicTopic[]>;
getLatestEntries(): Promise<LatestRecordEntry[]>;
getHomeFocusItems(): Promise<HomeFocusItem[]>;
searchPublicContent(query: string): Promise<SearchablePublicEntity[]>;
}>;
@@ -1,8 +1,8 @@
{
"packageId": "@tech-log/management-contract",
"version": "1.0.0",
"digest": "sha256:d19ae7c4fbcac924a356bbb0cc1a46a4046ecec701158ca0a9b7cc089bbaf878",
"sourceRevision": "b195b29",
"digest": "sha256:72650735061fde627f5037571eb986cb758f44a546f065c88408399f8eec4a55",
"sourceRevision": "06ae075",
"operationIds": [
"createCaseDraft",
"getCaseForEdit",
@@ -83,6 +83,7 @@
"updateHomeFocus",
"listStudioProjectActivities",
"createProjectActivity",
"updateProjectActivity"
"updateProjectActivity",
"deleteProjectActivity"
]
}
@@ -15,3 +15,8 @@ export type ReleaseIndexItem = Schemas["ReleaseIndexItem"];
export type ReleaseIndexPage = Schemas["ReleaseIndexPage"];
export type ReleaseUpdateRequest = Schemas["ReleaseUpdateRequest"];
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;
trace?: never;
};
"/api/v1/studio/home-focus": {
"/api/v1/studio/home/focus": {
parameters: {
query?: never;
header?: never;
@@ -883,7 +883,12 @@ export interface paths {
get?: never;
put: operations["updateProjectActivity"];
post?: never;
delete?: never;
/** @description . ,
* .
*
* `AUTO`
* . `MANUAL` . */
delete: operations["deleteProjectActivity"];
options?: never;
head?: never;
patch?: never;
@@ -963,6 +968,24 @@ export interface components {
data: components["schemas"]["ReleaseEditResponse"];
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: {
/** @constant */
success: true;
@@ -5519,7 +5542,7 @@ export interface operations {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["PublishResponse"];
"application/json": components["schemas"]["PublishResponseEnvelope"];
};
};
/** @description Bad Request */
@@ -5528,7 +5551,7 @@ export interface operations {
[name: string]: unknown;
};
content: {
"application/problem+json": components["schemas"]["ProblemDetails"];
"application/json": components["schemas"]["ErrorEnvelope"];
};
};
/** @description Unauthorized */
@@ -5537,7 +5560,7 @@ export interface operations {
[name: string]: unknown;
};
content: {
"application/problem+json": components["schemas"]["ProblemDetails"];
"application/json": components["schemas"]["ErrorEnvelope"];
};
};
/** @description Forbidden */
@@ -5546,7 +5569,7 @@ export interface operations {
[name: string]: unknown;
};
content: {
"application/problem+json": components["schemas"]["ProblemDetails"];
"application/json": components["schemas"]["ErrorEnvelope"];
};
};
/** @description Not Found */
@@ -5555,7 +5578,7 @@ export interface operations {
[name: string]: unknown;
};
content: {
"application/problem+json": components["schemas"]["ProblemDetails"];
"application/json": components["schemas"]["ErrorEnvelope"];
};
};
/** @description Conflict */
@@ -5564,7 +5587,7 @@ export interface operations {
[name: string]: unknown;
};
content: {
"application/problem+json": components["schemas"]["ProblemDetails"];
"application/json": components["schemas"]["ErrorEnvelope"];
};
};
/** @description Unprocessable Content */
@@ -5573,7 +5596,7 @@ export interface operations {
[name: string]: unknown;
};
content: {
"application/problem+json": components["schemas"]["ProblemDetails"];
"application/json": components["schemas"]["ErrorEnvelope"];
};
};
/** @description Internal Server Error */
@@ -5582,7 +5605,7 @@ export interface operations {
[name: string]: unknown;
};
content: {
"application/problem+json": components["schemas"]["ProblemDetails"];
"application/json": components["schemas"]["ErrorEnvelope"];
};
};
};
@@ -5610,7 +5633,7 @@ export interface operations {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["ProjectEditResponse"];
"application/json": components["schemas"]["ProjectEditResponseEnvelope"];
};
};
/** @description Bad Request */
@@ -5619,7 +5642,7 @@ export interface operations {
[name: string]: unknown;
};
content: {
"application/problem+json": components["schemas"]["ProblemDetails"];
"application/json": components["schemas"]["ErrorEnvelope"];
};
};
/** @description Unauthorized */
@@ -5628,7 +5651,7 @@ export interface operations {
[name: string]: unknown;
};
content: {
"application/problem+json": components["schemas"]["ProblemDetails"];
"application/json": components["schemas"]["ErrorEnvelope"];
};
};
/** @description Forbidden */
@@ -5637,7 +5660,7 @@ export interface operations {
[name: string]: unknown;
};
content: {
"application/problem+json": components["schemas"]["ProblemDetails"];
"application/json": components["schemas"]["ErrorEnvelope"];
};
};
/** @description Not Found */
@@ -5646,7 +5669,7 @@ export interface operations {
[name: string]: unknown;
};
content: {
"application/problem+json": components["schemas"]["ProblemDetails"];
"application/json": components["schemas"]["ErrorEnvelope"];
};
};
/** @description Conflict */
@@ -5655,7 +5678,7 @@ export interface operations {
[name: string]: unknown;
};
content: {
"application/problem+json": components["schemas"]["ProblemDetails"];
"application/json": components["schemas"]["ErrorEnvelope"];
};
};
/** @description Unprocessable Content */
@@ -5664,7 +5687,7 @@ export interface operations {
[name: string]: unknown;
};
content: {
"application/problem+json": components["schemas"]["ProblemDetails"];
"application/json": components["schemas"]["ErrorEnvelope"];
};
};
/** @description Internal Server Error */
@@ -5673,7 +5696,7 @@ export interface operations {
[name: string]: unknown;
};
content: {
"application/problem+json": components["schemas"]["ProblemDetails"];
"application/json": components["schemas"]["ErrorEnvelope"];
};
};
};
@@ -8117,7 +8140,7 @@ export interface operations {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["HomeFocusResponse"];
"application/json": components["schemas"]["HomeFocusResponseEnvelope"];
};
};
/** @description Bad Request */
@@ -8126,7 +8149,7 @@ export interface operations {
[name: string]: unknown;
};
content: {
"application/problem+json": components["schemas"]["ProblemDetails"];
"application/json": components["schemas"]["ErrorEnvelope"];
};
};
/** @description Unauthorized */
@@ -8135,7 +8158,7 @@ export interface operations {
[name: string]: unknown;
};
content: {
"application/problem+json": components["schemas"]["ProblemDetails"];
"application/json": components["schemas"]["ErrorEnvelope"];
};
};
/** @description Forbidden */
@@ -8144,7 +8167,7 @@ export interface operations {
[name: string]: unknown;
};
content: {
"application/problem+json": components["schemas"]["ProblemDetails"];
"application/json": components["schemas"]["ErrorEnvelope"];
};
};
/** @description Not Found */
@@ -8153,7 +8176,7 @@ export interface operations {
[name: string]: unknown;
};
content: {
"application/problem+json": components["schemas"]["ProblemDetails"];
"application/json": components["schemas"]["ErrorEnvelope"];
};
};
/** @description Internal Server Error */
@@ -8162,7 +8185,7 @@ export interface operations {
[name: string]: unknown;
};
content: {
"application/problem+json": components["schemas"]["ProblemDetails"];
"application/json": components["schemas"]["ErrorEnvelope"];
};
};
};
@@ -8188,7 +8211,7 @@ export interface operations {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["HomeFocusResponse"];
"application/json": components["schemas"]["HomeFocusResponseEnvelope"];
};
};
/** @description Bad Request */
@@ -8197,7 +8220,7 @@ export interface operations {
[name: string]: unknown;
};
content: {
"application/problem+json": components["schemas"]["ProblemDetails"];
"application/json": components["schemas"]["ErrorEnvelope"];
};
};
/** @description Unauthorized */
@@ -8206,7 +8229,7 @@ export interface operations {
[name: string]: unknown;
};
content: {
"application/problem+json": components["schemas"]["ProblemDetails"];
"application/json": components["schemas"]["ErrorEnvelope"];
};
};
/** @description Forbidden */
@@ -8215,7 +8238,7 @@ export interface operations {
[name: string]: unknown;
};
content: {
"application/problem+json": components["schemas"]["ProblemDetails"];
"application/json": components["schemas"]["ErrorEnvelope"];
};
};
/** @description Not Found */
@@ -8224,7 +8247,7 @@ export interface operations {
[name: string]: unknown;
};
content: {
"application/problem+json": components["schemas"]["ProblemDetails"];
"application/json": components["schemas"]["ErrorEnvelope"];
};
};
/** @description Conflict */
@@ -8233,7 +8256,7 @@ export interface operations {
[name: string]: unknown;
};
content: {
"application/problem+json": components["schemas"]["ProblemDetails"];
"application/json": components["schemas"]["ErrorEnvelope"];
};
};
/** @description Unprocessable Content */
@@ -8242,7 +8265,7 @@ export interface operations {
[name: string]: unknown;
};
content: {
"application/problem+json": components["schemas"]["ProblemDetails"];
"application/json": components["schemas"]["ErrorEnvelope"];
};
};
/** @description Internal Server Error */
@@ -8251,7 +8274,7 @@ export interface operations {
[name: string]: unknown;
};
content: {
"application/problem+json": components["schemas"]["ProblemDetails"];
"application/json": components["schemas"]["ErrorEnvelope"];
};
};
};
@@ -8273,7 +8296,7 @@ export interface operations {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["ProjectActivityResponse"][];
"application/json": components["schemas"]["ProjectActivityListEnvelope"];
};
};
/** @description 401 */
@@ -8282,7 +8305,7 @@ export interface operations {
[name: string]: unknown;
};
content: {
"application/problem+json": components["schemas"]["ProblemDetails"];
"application/json": components["schemas"]["ErrorEnvelope"];
};
};
/** @description 403 */
@@ -8291,7 +8314,7 @@ export interface operations {
[name: string]: unknown;
};
content: {
"application/problem+json": components["schemas"]["ProblemDetails"];
"application/json": components["schemas"]["ErrorEnvelope"];
};
};
/** @description 404 */
@@ -8300,7 +8323,7 @@ export interface operations {
[name: string]: unknown;
};
content: {
"application/problem+json": components["schemas"]["ProblemDetails"];
"application/json": components["schemas"]["ErrorEnvelope"];
};
};
/** @description 500 */
@@ -8309,7 +8332,7 @@ export interface operations {
[name: string]: unknown;
};
content: {
"application/problem+json": components["schemas"]["ProblemDetails"];
"application/json": components["schemas"]["ErrorEnvelope"];
};
};
};
@@ -8337,7 +8360,7 @@ export interface operations {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["ProjectActivityResponse"];
"application/json": components["schemas"]["ProjectActivityResponseEnvelope"];
};
};
/** @description 400 */
@@ -8346,7 +8369,7 @@ export interface operations {
[name: string]: unknown;
};
content: {
"application/problem+json": components["schemas"]["ProblemDetails"];
"application/json": components["schemas"]["ErrorEnvelope"];
};
};
/** @description 401 */
@@ -8355,7 +8378,7 @@ export interface operations {
[name: string]: unknown;
};
content: {
"application/problem+json": components["schemas"]["ProblemDetails"];
"application/json": components["schemas"]["ErrorEnvelope"];
};
};
/** @description 403 */
@@ -8364,7 +8387,7 @@ export interface operations {
[name: string]: unknown;
};
content: {
"application/problem+json": components["schemas"]["ProblemDetails"];
"application/json": components["schemas"]["ErrorEnvelope"];
};
};
/** @description 404 */
@@ -8373,7 +8396,7 @@ export interface operations {
[name: string]: unknown;
};
content: {
"application/problem+json": components["schemas"]["ProblemDetails"];
"application/json": components["schemas"]["ErrorEnvelope"];
};
};
/** @description 409 */
@@ -8382,7 +8405,7 @@ export interface operations {
[name: string]: unknown;
};
content: {
"application/problem+json": components["schemas"]["ProblemDetails"];
"application/json": components["schemas"]["ErrorEnvelope"];
};
};
/** @description 422 */
@@ -8391,7 +8414,7 @@ export interface operations {
[name: string]: unknown;
};
content: {
"application/problem+json": components["schemas"]["ProblemDetails"];
"application/json": components["schemas"]["ErrorEnvelope"];
};
};
/** @description 500 */
@@ -8400,7 +8423,7 @@ export interface operations {
[name: string]: unknown;
};
content: {
"application/problem+json": components["schemas"]["ProblemDetails"];
"application/json": components["schemas"]["ErrorEnvelope"];
};
};
};
@@ -8429,7 +8452,7 @@ export interface operations {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["ProjectActivityResponse"];
"application/json": components["schemas"]["ProjectActivityResponseEnvelope"];
};
};
/** @description 400 */
@@ -8438,7 +8461,7 @@ export interface operations {
[name: string]: unknown;
};
content: {
"application/problem+json": components["schemas"]["ProblemDetails"];
"application/json": components["schemas"]["ErrorEnvelope"];
};
};
/** @description 401 */
@@ -8447,7 +8470,7 @@ export interface operations {
[name: string]: unknown;
};
content: {
"application/problem+json": components["schemas"]["ProblemDetails"];
"application/json": components["schemas"]["ErrorEnvelope"];
};
};
/** @description 403 */
@@ -8456,7 +8479,7 @@ export interface operations {
[name: string]: unknown;
};
content: {
"application/problem+json": components["schemas"]["ProblemDetails"];
"application/json": components["schemas"]["ErrorEnvelope"];
};
};
/** @description 404 */
@@ -8465,7 +8488,7 @@ export interface operations {
[name: string]: unknown;
};
content: {
"application/problem+json": components["schemas"]["ProblemDetails"];
"application/json": components["schemas"]["ErrorEnvelope"];
};
};
/** @description 409 */
@@ -8474,7 +8497,7 @@ export interface operations {
[name: string]: unknown;
};
content: {
"application/problem+json": components["schemas"]["ProblemDetails"];
"application/json": components["schemas"]["ErrorEnvelope"];
};
};
/** @description 422 */
@@ -8483,7 +8506,7 @@ export interface operations {
[name: string]: unknown;
};
content: {
"application/problem+json": components["schemas"]["ProblemDetails"];
"application/json": components["schemas"]["ErrorEnvelope"];
};
};
/** @description 500 */
@@ -8492,7 +8515,97 @@ export interface operations {
[name: string]: unknown;
};
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:
application/json:
schema:
$ref: '#/components/schemas/PublishResponse'
$ref: '#/components/schemas/PublishResponseEnvelope'
'400':
description: Bad Request
content:
application/problem+json:
application/json:
schema:
$ref: '#/components/schemas/ProblemDetails'
$ref: '#/components/schemas/ErrorEnvelope'
'401':
description: Unauthorized
content:
application/problem+json:
application/json:
schema:
$ref: '#/components/schemas/ProblemDetails'
$ref: '#/components/schemas/ErrorEnvelope'
'403':
description: Forbidden
content:
application/problem+json:
application/json:
schema:
$ref: '#/components/schemas/ProblemDetails'
$ref: '#/components/schemas/ErrorEnvelope'
'404':
description: Not Found
content:
application/problem+json:
application/json:
schema:
$ref: '#/components/schemas/ProblemDetails'
$ref: '#/components/schemas/ErrorEnvelope'
'409':
description: Conflict
content:
application/problem+json:
application/json:
schema:
$ref: '#/components/schemas/ProblemDetails'
$ref: '#/components/schemas/ErrorEnvelope'
'422':
description: Unprocessable Content
content:
application/problem+json:
application/json:
schema:
$ref: '#/components/schemas/ProblemDetails'
$ref: '#/components/schemas/ErrorEnvelope'
'500':
description: Internal Server Error
content:
application/problem+json:
application/json:
schema:
$ref: '#/components/schemas/ProblemDetails'
$ref: '#/components/schemas/ErrorEnvelope'
requestBody:
required: true
content:
@@ -3085,49 +3085,49 @@ paths:
content:
application/json:
schema:
$ref: '#/components/schemas/ProjectEditResponse'
$ref: '#/components/schemas/ProjectEditResponseEnvelope'
'400':
description: Bad Request
content:
application/problem+json:
application/json:
schema:
$ref: '#/components/schemas/ProblemDetails'
$ref: '#/components/schemas/ErrorEnvelope'
'401':
description: Unauthorized
content:
application/problem+json:
application/json:
schema:
$ref: '#/components/schemas/ProblemDetails'
$ref: '#/components/schemas/ErrorEnvelope'
'403':
description: Forbidden
content:
application/problem+json:
application/json:
schema:
$ref: '#/components/schemas/ProblemDetails'
$ref: '#/components/schemas/ErrorEnvelope'
'404':
description: Not Found
content:
application/problem+json:
application/json:
schema:
$ref: '#/components/schemas/ProblemDetails'
$ref: '#/components/schemas/ErrorEnvelope'
'409':
description: Conflict
content:
application/problem+json:
application/json:
schema:
$ref: '#/components/schemas/ProblemDetails'
$ref: '#/components/schemas/ErrorEnvelope'
'422':
description: Unprocessable Content
content:
application/problem+json:
application/json:
schema:
$ref: '#/components/schemas/ProblemDetails'
$ref: '#/components/schemas/ErrorEnvelope'
'500':
description: Internal Server Error
content:
application/problem+json:
application/json:
schema:
$ref: '#/components/schemas/ProblemDetails'
$ref: '#/components/schemas/ErrorEnvelope'
requestBody:
required: true
content:
@@ -4973,7 +4973,7 @@ paths:
$ref: '#/components/schemas/ExpectedVersionRequest'
security:
- sessionCookie: []
/api/v1/studio/home-focus:
/api/v1/studio/home/focus:
get:
operationId: getHomeFocus
tags:
@@ -4985,37 +4985,37 @@ paths:
content:
application/json:
schema:
$ref: '#/components/schemas/HomeFocusResponse'
$ref: '#/components/schemas/HomeFocusResponseEnvelope'
'400':
description: Bad Request
content:
application/problem+json:
application/json:
schema:
$ref: '#/components/schemas/ProblemDetails'
$ref: '#/components/schemas/ErrorEnvelope'
'401':
description: Unauthorized
content:
application/problem+json:
application/json:
schema:
$ref: '#/components/schemas/ProblemDetails'
$ref: '#/components/schemas/ErrorEnvelope'
'403':
description: Forbidden
content:
application/problem+json:
application/json:
schema:
$ref: '#/components/schemas/ProblemDetails'
$ref: '#/components/schemas/ErrorEnvelope'
'404':
description: Not Found
content:
application/problem+json:
application/json:
schema:
$ref: '#/components/schemas/ProblemDetails'
$ref: '#/components/schemas/ErrorEnvelope'
'500':
description: Internal Server Error
content:
application/problem+json:
application/json:
schema:
$ref: '#/components/schemas/ProblemDetails'
$ref: '#/components/schemas/ErrorEnvelope'
security:
- sessionCookie: []
put:
@@ -5030,49 +5030,49 @@ paths:
content:
application/json:
schema:
$ref: '#/components/schemas/HomeFocusResponse'
$ref: '#/components/schemas/HomeFocusResponseEnvelope'
'400':
description: Bad Request
content:
application/problem+json:
application/json:
schema:
$ref: '#/components/schemas/ProblemDetails'
$ref: '#/components/schemas/ErrorEnvelope'
'401':
description: Unauthorized
content:
application/problem+json:
application/json:
schema:
$ref: '#/components/schemas/ProblemDetails'
$ref: '#/components/schemas/ErrorEnvelope'
'403':
description: Forbidden
content:
application/problem+json:
application/json:
schema:
$ref: '#/components/schemas/ProblemDetails'
$ref: '#/components/schemas/ErrorEnvelope'
'404':
description: Not Found
content:
application/problem+json:
application/json:
schema:
$ref: '#/components/schemas/ProblemDetails'
$ref: '#/components/schemas/ErrorEnvelope'
'409':
description: Conflict
content:
application/problem+json:
application/json:
schema:
$ref: '#/components/schemas/ProblemDetails'
$ref: '#/components/schemas/ErrorEnvelope'
'422':
description: Unprocessable Content
content:
application/problem+json:
application/json:
schema:
$ref: '#/components/schemas/ProblemDetails'
$ref: '#/components/schemas/ErrorEnvelope'
'500':
description: Internal Server Error
content:
application/problem+json:
application/json:
schema:
$ref: '#/components/schemas/ProblemDetails'
$ref: '#/components/schemas/ErrorEnvelope'
requestBody:
required: true
content:
@@ -5101,33 +5101,31 @@ paths:
content:
application/json:
schema:
type: array
items:
$ref: '#/components/schemas/ProjectActivityResponse'
$ref: '#/components/schemas/ProjectActivityListEnvelope'
'401':
description: '401'
content:
application/problem+json:
application/json:
schema:
$ref: '#/components/schemas/ProblemDetails'
$ref: '#/components/schemas/ErrorEnvelope'
'403':
description: '403'
content:
application/problem+json:
application/json:
schema:
$ref: '#/components/schemas/ProblemDetails'
$ref: '#/components/schemas/ErrorEnvelope'
'404':
description: '404'
content:
application/problem+json:
application/json:
schema:
$ref: '#/components/schemas/ProblemDetails'
$ref: '#/components/schemas/ErrorEnvelope'
'500':
description: '500'
content:
application/problem+json:
application/json:
schema:
$ref: '#/components/schemas/ProblemDetails'
$ref: '#/components/schemas/ErrorEnvelope'
post:
operationId: createProjectActivity
tags:
@@ -5154,49 +5152,49 @@ paths:
content:
application/json:
schema:
$ref: '#/components/schemas/ProjectActivityResponse'
$ref: '#/components/schemas/ProjectActivityResponseEnvelope'
'400':
description: '400'
content:
application/problem+json:
application/json:
schema:
$ref: '#/components/schemas/ProblemDetails'
$ref: '#/components/schemas/ErrorEnvelope'
'401':
description: '401'
content:
application/problem+json:
application/json:
schema:
$ref: '#/components/schemas/ProblemDetails'
$ref: '#/components/schemas/ErrorEnvelope'
'403':
description: '403'
content:
application/problem+json:
application/json:
schema:
$ref: '#/components/schemas/ProblemDetails'
$ref: '#/components/schemas/ErrorEnvelope'
'404':
description: '404'
content:
application/problem+json:
application/json:
schema:
$ref: '#/components/schemas/ProblemDetails'
$ref: '#/components/schemas/ErrorEnvelope'
'409':
description: '409'
content:
application/problem+json:
application/json:
schema:
$ref: '#/components/schemas/ProblemDetails'
$ref: '#/components/schemas/ErrorEnvelope'
'422':
description: '422'
content:
application/problem+json:
application/json:
schema:
$ref: '#/components/schemas/ProblemDetails'
$ref: '#/components/schemas/ErrorEnvelope'
'500':
description: '500'
content:
application/problem+json:
application/json:
schema:
$ref: '#/components/schemas/ProblemDetails'
$ref: '#/components/schemas/ErrorEnvelope'
/api/v1/studio/projects/{id}/activities/{activityId}:
put:
operationId: updateProjectActivity
@@ -5230,49 +5228,126 @@ paths:
content:
application/json:
schema:
$ref: '#/components/schemas/ProjectActivityResponse'
$ref: '#/components/schemas/ProjectActivityResponseEnvelope'
'400':
description: '400'
content:
application/problem+json:
application/json:
schema:
$ref: '#/components/schemas/ProblemDetails'
$ref: '#/components/schemas/ErrorEnvelope'
'401':
description: '401'
content:
application/problem+json:
application/json:
schema:
$ref: '#/components/schemas/ProblemDetails'
$ref: '#/components/schemas/ErrorEnvelope'
'403':
description: '403'
content:
application/problem+json:
application/json:
schema:
$ref: '#/components/schemas/ProblemDetails'
$ref: '#/components/schemas/ErrorEnvelope'
'404':
description: '404'
content:
application/problem+json:
application/json:
schema:
$ref: '#/components/schemas/ProblemDetails'
$ref: '#/components/schemas/ErrorEnvelope'
'409':
description: '409'
content:
application/problem+json:
application/json:
schema:
$ref: '#/components/schemas/ProblemDetails'
$ref: '#/components/schemas/ErrorEnvelope'
'422':
description: '422'
content:
application/problem+json:
application/json:
schema:
$ref: '#/components/schemas/ProblemDetails'
$ref: '#/components/schemas/ErrorEnvelope'
'500':
description: '500'
content:
application/problem+json:
application/json:
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:
securitySchemes:
sessionCookie:
@@ -5510,6 +5585,53 @@ components:
$ref: '#/components/schemas/ReleaseEditResponse'
meta:
$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:
type: object
additionalProperties: false
@@ -1,8 +1,8 @@
{
"packageId": "@tech-log/public-contract",
"version": "2.1.0",
"digest": "sha256:37e6f804165de3e492e975075bea563ee41ae74076222a3562d3452631bfdb2b",
"sourceRevision": "b195b29",
"digest": "sha256:702d6666a8feba9899c7eb7c2a94a0880bcb23b178c7ed2009c6e69d9a1c848c",
"sourceRevision": "06ae075",
"operationIds": [
"getPublicSite",
"getPublicHome",
@@ -670,6 +670,9 @@ export interface components {
nextStep?: string;
systemOverviewMarkdown?: string;
technologies?: string[];
/** @description 이 프로젝트가 다루는 주제. 프로젝트 화면의 "주요 주제" 가 이 목록을 그린다.
* 화면은 처음부터 이 값을 읽고 있었지만 계약에 자리가 없어 늘 비어 있었다. */
topics?: components["schemas"]["TopicSummary"][];
/** Format: date-time */
updatedAt: string;
};
@@ -1572,6 +1572,13 @@ components:
type: array
items:
type: string
topics:
type: array
description: |-
이 프로젝트가 다루는 주제. 프로젝트 화면의 "주요 주제" 가 이 목록을 그린다.
화면은 처음부터 이 값을 읽고 있었지만 계약에 자리가 없어 늘 비어 있었다.
items:
$ref: '#/components/schemas/TopicSummary'
updatedAt: *id003
featuredDecision:
$ref: '#/components/schemas/RelatedEntry'
@@ -1,8 +1,8 @@
{
"packageId": "@tech-log/studio-contract",
"version": "3.0.0",
"digest": "sha256:674327a82951fd4a1bc2594c858072dfcd0b9abe7c63198283da5d8c92a04326",
"sourceRevision": "b195b29",
"version": "3.1.0",
"digest": "sha256:6fc015ca6727af88b7fb0088e02ba97846e1dd79fb0d4fc593cc79f2a3b9795f",
"sourceRevision": "06ae075",
"operationIds": [
"getStudioSession",
"getStudioDashboard",
@@ -1074,7 +1074,32 @@ export interface components {
height: number | null;
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"] & {
/** @enum {string} */
kind: "CASE";
@@ -1,7 +1,7 @@
openapi: 3.1.0
info:
title: Tech Log Studio API
version: 3.0.0
version: 3.1.0
description: |
Tech Log Studio orchestration 계약이다.
@@ -1321,7 +1321,10 @@ components:
properties:
type: { type: string, enum: [HEADING] }
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" } }
ParagraphBlock:
type: object
@@ -1452,6 +1455,29 @@ components:
width: { type: [integer, "null"], minimum: 1 }
height: { type: [integer, "null"], minimum: 1 }
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:
oneOf:
- { $ref: "#/components/schemas/HeadingBlock" }
@@ -1463,6 +1489,8 @@ components:
- { $ref: "#/components/schemas/DataTableBlock" }
- { $ref: "#/components/schemas/CalloutBlock" }
- { $ref: "#/components/schemas/EvidenceFigureBlock" }
- { $ref: "#/components/schemas/ThematicBreakBlock" }
- { $ref: "#/components/schemas/ImageBlock" }
discriminator:
propertyName: type
mapping:
@@ -1475,6 +1503,8 @@ components:
DATA_TABLE: "#/components/schemas/DataTableBlock"
CALLOUT: "#/components/schemas/CalloutBlock"
EVIDENCE_FIGURE: "#/components/schemas/EvidenceFigureBlock"
THEMATIC_BREAK: "#/components/schemas/ThematicBreakBlock"
IMAGE: "#/components/schemas/ImageBlock"
CasePublicRenderModel:
unevaluatedProperties: false
allOf:
@@ -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(
"publishRelease",
"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_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_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_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: "NOT_FOUND", path: "*", layoutGroup: "PUBLIC", paramsSchema: "NotFoundSplat", searchSchema: null, title: "페이지를 찾을 수 없습니다.", navigationLabel: null, navigationOrder: null }),
] as const;
@@ -180,14 +180,27 @@ function normalizeDirectives(source: string): string {
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(
/^(:::[a-z][a-z0-9-]*)([^\n{][^\n]*)(\n?)$/i,
/^(:::[a-z][a-z0-9-]*)(\s+[^\n]*?)(\n?)$/i,
(
_match,
marker: string,
attributes: string,
newline: string,
) => `${marker}{${attributes.trim()}}${newline}`,
) => {
const trimmed = attributes.trim();
return trimmed ? `${marker}{${trimmed}}${newline}` : `${marker}${newline}`;
},
);
})
.join("");
@@ -199,6 +212,14 @@ function assertNever(value: never): never {
const trustedRelativeLinkOrigin = "https://techlog.invalid";
/** 서버가 아는 callout 이름과 화면에 붙일 말. 이름이 tone 을 겸하므로 속성을 받지 않는다. */
const CALLOUT_LABELS: Readonly<Record<string, string>> = {
note: "참고",
tip: "도움말",
warning: "주의",
danger: "위험",
};
function hasAsciiControlCharacter(value: string): boolean {
return Array.from(value).some((character) => {
const codePoint = character.codePointAt(0) ?? 0;
@@ -326,8 +347,8 @@ function headingBlock(
node: Heading,
usedIds: Set<string>,
): components["schemas"]["HeadingBlock"] {
if (node.depth < 2 || node.depth > 4) {
invalid(node, "only heading levels 2 through 4 are supported");
if (node.depth < 1 || node.depth > 6) {
invalid(node, "only heading levels 1 through 6 are supported");
}
const children = [...node.children];
@@ -417,15 +438,34 @@ function tableCellContent(cell: TableCell): Inline[] {
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(
node: ContainerDirective,
node: ContainerDirective | Table,
usedIds: Set<string>,
bareIndex?: number,
): components["schemas"]["DataTableBlock"] {
const attributes = attributesOf(node, ["id", "caption", "rowHeaderColumn"]);
if (node.children.length !== 1 || node.children[0].type !== "table") {
invalid(node, "table directive must contain exactly one GFM table");
const bare = node.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");
}
}
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");
const id = attributes.id;
@@ -506,6 +546,25 @@ function directiveBlock(
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": {
const attributes = attributesOf(node, ["key", "alt", "caption", "zoom"]);
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(
node: Paragraph,
): components["schemas"]["ParagraphBlock"] {
return { type: "PARAGRAPH", content: inlineFromNodes(node.children) };
): components["schemas"]["ParagraphBlock"] | components["schemas"]["ImageBlock"] {
return imageBlockOf(node) ?? { type: "PARAGRAPH", content: inlineFromNodes(node.children) };
}
export function parseCaseContent(source: string): CaseAuthoringBlock[] {
@@ -564,6 +647,7 @@ export function parseCaseContent(source: string): CaseAuthoringBlock[] {
listItemCount += 1;
return `list-item-${listItemCount}`;
};
let bareTableCount = 0;
return tree.children.map((node: Content): CaseAuthoringBlock => {
switch (node.type) {
@@ -585,11 +669,17 @@ export function parseCaseContent(source: string): CaseAuthoringBlock[] {
return codeBlock(node);
case "containerDirective":
return directiveBlock(node, usedIds);
case "html":
case "table": {
// 서버 렌더러는 파이프 표를 그대로 읽는다. 여기서 거절하면 같은 본문이 Studio 와
// 공개 화면에서 다르게 읽힌다.
bareTableCount += 1;
return tableBlock(node, usedIds, bareTableCount);
}
case "thematicBreak":
return { type: "THEMATIC_BREAK" };
case "html":
case "definition":
case "yaml":
case "table":
case "footnoteDefinition":
case "leafDirective":
return invalid(node, `unsupported block syntax: ${node.type}`);
@@ -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))}`,
":::",
].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:
return assertNever(block);
}
@@ -11,3 +11,28 @@ export function createLocalId(
const entropy = Math.floor(random() * 1_000_000_000).toString().padStart(9, "0");
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)}`;
}
@@ -39,56 +39,37 @@ function optionalString(value: unknown): string | undefined {
return typeof value === "string" ? value : undefined;
}
/**
* 서버가 고른 최근 기록에 릴리스를 얹는다.
*
* 예전에는 이 함수가 공개된 프로젝트를 하나씩 돌며 활동을 모아 타임라인을 만들었다. 그래서 게시된
* 문서라도 그 문서가 매달린 프로젝트가 공개되어 있지 않으면 홈에서 통째로 사라졌다 — 실제로 Case 를
* 게시했는데 홈에는 릴리스 한 줄만 남았다. 무엇이 최근인지는 공개 투영이 이미 알고 있으므로 그것을
* 그대로 읽는다. 프로젝트마다 요청을 하나씩 보내던 N+1 도 같이 사라진다.
*
* 릴리스는 Publication 파이프라인을 거치지 않아 그 투영에 행이 없다. 그래서 릴리스만 따로 읽어
* 시간순으로 합친다.
*/
async function getLatestEntries(
publicContent: PublicContentQueries,
): Promise<LatestEntry[]> {
const publicRecords = await publicContent.listRecords();
const publicRecordByPath = new Map(
publicRecords.map((record) => [record.path, record]),
);
const searchableEntities = await publicContent.searchPublicContent("");
const projectPrefix = "/projects/";
const projectSlugs = searchableEntities
.filter((entity) => entity.contentType === "PROJECT")
.flatMap((entity) =>
entity.path.startsWith(projectPrefix)
? [decodeURIComponent(entity.path.slice(projectPrefix.length))]
: [],
);
// One project at a time would serialise a request per project; issuing them
// together keeps the timeline's cost at its slowest project rather than their
// sum. The flatten below restores the original single-list shape.
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 [records, searchableEntities] = await Promise.all([
publicContent.getLatestEntries(),
publicContent.searchPublicContent(""),
]);
const recordTimeline: LatestEntry[] = records.map((entry) => ({
id: entry.id,
typeLabel: entry.entryType === "PROJECT_ACTIVITY" ? "PROJECT ACTIVITY" : entry.entryType,
title: entry.title,
summary: entry.summary,
date: dateLabel(entry.publishedAt),
dateTime: entry.publishedAt,
topic: entry.topic,
project: entry.project,
path: entry.path,
}));
const releaseTimeline = (
await Promise.all(
searchableEntities
@@ -102,14 +83,14 @@ async function getLatestEntries(
if (!release) return [];
return [
{
id: `release-${release.version}`,
typeLabel: "RELEASE",
title: release.title,
summary: release.summary,
date: release.publishedLabel,
dateTime: release.publishedAt,
topic: "TechLog",
project: "TechLog",
id: `release-${release.version}`,
typeLabel: "RELEASE",
title: release.title,
summary: release.summary,
date: release.publishedLabel,
dateTime: release.publishedAt,
topic: "TechLog",
project: "TechLog",
path: release.path,
},
];
@@ -117,11 +98,20 @@ async function getLatestEntries(
)
).flat();
return [...projectTimeline, ...releaseTimeline].sort((left, right) =>
return [...recordTimeline, ...releaseTimeline].sort((left, right) =>
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() {
const { search } = useRouteInput<"TECH_LOG_HOME">();
const requestedKey = optionalString(search.focus);
@@ -99,6 +99,19 @@ function renderBlock(
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:
return assertNever(block);
}
@@ -12,6 +12,7 @@ type DocumentTocProps = {
export function DocumentToc({ headings, variant }: DocumentTocProps) {
const [currentId, setCurrentId] = useState(headings[0]?.id ?? "");
const empty = headings.length === 0;
const detailsRef = useRef<HTMLDetailsElement>(null);
useEffect(() => {
@@ -41,6 +42,12 @@ export function DocumentToc({ headings, variant }: DocumentTocProps) {
if (detailsRef.current) detailsRef.current.open = false;
}
/*
제목이 없는 문서에는 목차를 그리지 않는다. 예전에는 이 컴포넌트가 픽스처 문서 하나에서만
쓰여 빈 경우를 만날 일이 없었지만, 이제 모든 Case 가 지나므로 빈 레일이 남을 수 있다.
*/
if (empty) return null;
if (variant === "mobile") {
const current =
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,
embedded,
resolveEvidenceAsset,
@@ -233,7 +194,7 @@ function FetchJoinCase({
<dd><PlainText text={model.environment} /></dd>
</div>
<div>
<dt></dt>
<dt> </dt>
<dd><PlainText text={model.reproduction.replace(/^Dataset:\s*/, "")} /></dd>
</div>
<div>
@@ -519,16 +480,8 @@ export function PublicRecordRenderer({
} & RenderDependencies) {
switch (model.kind) {
case "CASE":
return model.publicPath ===
"/cases/collection-fetch-join-pagination" ? (
<FetchJoinCase
model={model}
embedded={embedded}
resolveEvidenceAsset={resolveEvidenceAsset}
resolvePublishedLabel={resolvePublishedLabel}
/>
) : (
<GenericCase
return (
<CaseDocument
model={model}
embedded={embedded}
resolveEvidenceAsset={resolveEvidenceAsset}
@@ -0,0 +1,194 @@
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;
void Promise.all([
managementGateway.getHomeFocus(),
managementGateway.listProjects(0, 50),
gateway.getCatalog({ type: "RELATION", limit: 100 }),
]).then(
([current, projectPage, catalog]) => {
if (cancelled) return;
setFocus(current);
setProjects(projectPage.items ?? []);
setRelations(catalog.items ?? []);
setProjectId(current.currentProjectId ?? "");
setQuestionId(current.openQuestionId ?? "");
setDecisionId(current.recentDecisionId ?? "");
setError("");
},
() => {
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 { createLocalId } from "../../../domain/studio/local-id.ts";
import { createNewItemId } from "../../../domain/studio/local-id.ts";
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>
))}
<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>
);
}
@@ -0,0 +1,676 @@
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 { 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 [newActivity, setNewActivity] = useState({
activityType: "MILESTONE_REACHED",
title: "",
summary: "",
visibility: "PUBLIC",
relatedId: "",
occurredAt: toLocalInput(new Date().toISOString()),
});
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 Promise.all([
managementGateway.getProject(projectId),
managementGateway.listProjectActivities(projectId),
managementGateway.listTopics(),
gateway.getCatalog({ type: "RELATION", limit: 100 }),
]).then(
([loaded, activityList, topicList, catalog]) => {
if (cancelled) return;
setProject(loaded);
setDraft(toDraft(loaded));
setActivities(activityList);
setTopics(topicList.filter((topic) => topic.status !== "ARCHIVED"));
setRecords(catalog.items ?? []);
setError("");
},
() => {
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);
}
};
const addActivity = async (event: FormEvent) => {
event.preventDefault();
if (pending || !project) return;
const title = newActivity.title.trim();
if (!title) {
setError("활동에는 제목이 필요합니다.");
return;
}
setPending(true);
setError("");
try {
const related = records.find((entry) => entry.id === newActivity.relatedId);
await managementGateway.createProjectActivity(project.id, {
expectedProjectVersion: project.version,
activityType: newActivity.activityType,
title,
summary: newActivity.summary,
visibility: newActivity.visibility === "PUBLIC" ? "PUBLIC" : "PRIVATE",
/*
공개 화면의 "이 활동 위치 열기" 는 이 자리를 따라간다. 비워 두면 그 링크는 갈 곳이 없다 —
예전에는 고를 방법 자체가 없어 모든 활동의 그 버튼이 눌리지 않았다.
*/
relatedResourceType: related?.kind ?? undefined,
relatedResourceId: related?.id ?? undefined,
occurredAt: fromLocalInput(newActivity.occurredAt),
});
setNewActivity({
activityType: "MILESTONE_REACHED",
title: "",
summary: "",
visibility: "PUBLIC",
relatedId: "",
occurredAt: toLocalInput(new Date().toISOString()),
});
setRequestAnnouncement(`활동 ${title} 을(를) 추가했습니다.`);
reload();
} catch (failure) {
setError(managementFailureMessage(failure, "활동을 추가하지 못했습니다."));
} finally {
setPending(false);
}
};
const saveActivity = async (
activity: ProjectActivityResponse,
changes: Partial<Pick<ProjectActivityResponse, "title" | "summary" | "visibility" | "occurredAt">>,
) => {
if (pending || !project) return;
setPending(true);
setError("");
try {
const saved = await managementGateway.updateProjectActivity(project.id, activity.id, {
expectedVersion: activity.version,
title: changes.title ?? activity.title,
summary: changes.summary ?? activity.summary ?? "",
visibility: (changes.visibility ?? activity.visibility) === "PUBLIC" ? "PUBLIC" : "PRIVATE",
occurredAt: changes.occurredAt ?? activity.occurredAt,
});
setActivities((current) => current.map((item) => (item.id === saved.id ? saved : item)));
setRequestAnnouncement("활동을 저장했습니다.");
} catch (failure) {
setError(managementFailureMessage(failure, "활동을 저장하지 못했습니다."));
} finally {
setPending(false);
}
};
const removeActivity = async (activity: ProjectActivityResponse) => {
if (pending || !project) return;
setPending(true);
setError("");
try {
await managementGateway.deleteProjectActivity(project.id, activity.id, activity.version);
setActivities((current) => current.filter((item) => item.id !== activity.id));
setRequestAnnouncement(`활동 ${activity.title} 을(를) 삭제했습니다.`);
} catch (failure) {
setError(managementFailureMessage(failure, "활동을 삭제하지 못했습니다."));
} finally {
setPending(false);
}
};
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> . .</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 })}
/>
</label>
<label className="studio-field studio-field--wide">
<span></span>
<textarea
className="studio-markdown"
value={draft.boundaryMarkdown}
onChange={(event) => update({ boundaryMarkdown: event.currentTarget.value })}
/>
</label>
<label className="studio-field studio-field--wide">
<span> </span>
<textarea
className="studio-markdown"
value={draft.systemOverviewMarkdown}
onChange={(event) =>
update({ systemOverviewMarkdown: event.currentTarget.value })
}
/>
</label>
</div>
</section>
<div className="studio-editor-footer">
<button className="studio-primary-button" type="submit" 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>
<form className="studio-field-grid" onSubmit={addActivity}>
<label className="studio-field">
<span></span>
<select
value={newActivity.activityType}
onChange={(event) => {
// setState 업데이터는 다음 렌더에 실행된다. 그때 `event.currentTarget` 은 이미
// null 이므로, 값은 핸들러가 도는 동안 꺼내 둬야 한다.
const value = event.currentTarget.value;
setNewActivity((current) => ({ ...current, activityType: value }));
}}
>
{ACTIVITY_TYPES.map((type) => (
<option key={type.value} value={type.value}>
{type.label}
</option>
))}
</select>
</label>
<label className="studio-field">
<span></span>
<input
type="datetime-local"
value={newActivity.occurredAt}
onChange={(event) => {
// setState 업데이터는 다음 렌더에 실행된다. 그때 `event.currentTarget` 은 이미
// null 이므로, 값은 핸들러가 도는 동안 꺼내 둬야 한다.
const value = event.currentTarget.value;
setNewActivity((current) => ({ ...current, occurredAt: value }));
}}
/>
</label>
<label className="studio-field studio-field--wide">
<span></span>
<input
value={newActivity.title}
maxLength={200}
onChange={(event) => {
// setState 업데이터는 다음 렌더에 실행된다. 그때 `event.currentTarget` 은 이미
// null 이므로, 값은 핸들러가 도는 동안 꺼내 둬야 한다.
const value = event.currentTarget.value;
setNewActivity((current) => ({ ...current, title: value }));
}}
/>
</label>
<label className="studio-field studio-field--wide">
<span></span>
<input
value={newActivity.summary}
maxLength={300}
onChange={(event) => {
// setState 업데이터는 다음 렌더에 실행된다. 그때 `event.currentTarget` 은 이미
// null 이므로, 값은 핸들러가 도는 동안 꺼내 둬야 한다.
const value = event.currentTarget.value;
setNewActivity((current) => ({ ...current, summary: value }));
}}
/>
</label>
<label className="studio-field">
<span> </span>
<select
value={newActivity.relatedId}
onChange={(event) => {
// setState 업데이터는 다음 렌더에 실행된다. 그때 `event.currentTarget` 은 이미
// null 이므로, 값은 핸들러가 도는 동안 꺼내 둬야 한다.
const value = event.currentTarget.value;
setNewActivity((current) => ({ ...current, relatedId: value }));
}}
>
<option value=""> </option>
{records.map((entry) => (
<option key={entry.id} value={entry.id}>
{entry.label}
</option>
))}
</select>
<small>
&ldquo; &rdquo; .
</small>
</label>
<label className="studio-field">
<span></span>
<select
value={newActivity.visibility}
onChange={(event) => {
// setState 업데이터는 다음 렌더에 실행된다. 그때 `event.currentTarget` 은 이미
// null 이므로, 값은 핸들러가 도는 동안 꺼내 둬야 한다.
const value = event.currentTarget.value;
setNewActivity((current) => ({ ...current, visibility: value }));
}}
>
<option value="PUBLIC"></option>
<option value="PRIVATE"></option>
</select>
</label>
<div className="studio-field studio-field--wide">
<button className="studio-secondary-button" type="submit" disabled={pending}>
</button>
</div>
</form>
{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.summary || "요약 없음"}
{activity.relatedResourceId ? " · 기록 연결됨" : " · 연결된 기록 없음"}
</p>
</div>
<dl>
<div>
<dt></dt>
<dd>
<input
type="datetime-local"
aria-label={`${activity.title} 일시`}
defaultValue={toLocalInput(activity.occurredAt)}
onBlur={(event) =>
void saveActivity(activity, {
occurredAt: fromLocalInput(event.currentTarget.value),
})
}
/>
</dd>
</div>
<div>
<dt></dt>
<dd>
<select
aria-label={`${activity.title} 노출`}
value={activity.visibility}
onChange={(event) =>
void saveActivity(activity, {
visibility: event.currentTarget.value,
})
}
>
<option value="PUBLIC"></option>
<option value="PRIVATE"></option>
</select>
</dd>
</div>
<div>
<dt></dt>
<dd>{activity.origin === "MANUAL" ? "손으로 적음" : "자동 기록"}</dd>
</div>
</dl>
<button
className="studio-secondary-button"
type="button"
disabled={pending || activity.origin !== "MANUAL"}
title={
activity.origin === "MANUAL"
? undefined
: "게시 같은 실제 사건이 남긴 줄은 지울 수 없습니다."
}
onClick={() => void removeActivity(activity)}
>
</button>
</article>
))}
</div>
)}
</section>
</>
) : null}
</div>
);
}
@@ -1,5 +1,5 @@
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 { 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>
<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>)}
<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>
<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>
@@ -1,5 +1,5 @@
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 { 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>
<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>)}
<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>
<OrderedTextList label="적용 조건" items={draft.applyWhen} onChange={(applyWhen) => update({ applyWhen })} />
<FieldNotice issues={issues} path="/applyWhen" />
@@ -1,5 +1,5 @@
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 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>
))}
<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>
);
}
@@ -0,0 +1,338 @@
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 { 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);
}
};
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"
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,
} 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";
/**
@@ -81,13 +82,13 @@ const STATUS_LABELS: Readonly<Record<string, string>> = {
export function ReleaseManager() {
const { managementGateway, setRequestAnnouncement } = useStudio();
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 [error, setError] = useState("");
const [pending, setPending] = useState(false);
const [generation, setGeneration] = useState(0);
const [newTitle, setNewTitle] = useState("");
/* 방금 만든 릴리즈를 목록에서 짚어 준다 — 목록이 길면 어느 것이 새것인지 알기 어렵다. */
const [createdId, setCreatedId] = useState<string | null>(null);
const reload = useCallback(() => setGeneration((value) => value + 1), []);
@@ -112,29 +113,6 @@ export function ReleaseManager() {
};
}, [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) => {
event.preventDefault();
if (pending) return;
@@ -148,8 +126,10 @@ export function ReleaseManager() {
try {
const created = await managementGateway.createRelease(title);
setNewTitle("");
setRequestAnnouncement(`릴리즈 ${title} 초안을 만들었습니다.`);
setSelectedId(created.id);
setRequestAnnouncement(
`릴리즈 ${title} 초안을 만들었습니다. 목록에서 편집을 눌러 내용을 채웁니다.`,
);
setCreatedId(created.id);
reload();
} catch (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) => {
if (pending) return;
setPending(true);
setError("");
try {
await managementGateway.deleteRelease(release.id, release.version);
if (selectedId === release.id) setSelectedId(null);
setRequestAnnouncement(`릴리즈 ${release.versionLabel} 을(를) 삭제했습니다.`);
reload();
} 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 (
<div className="studio-page studio-documents-page">
<header className="studio-page-top">
@@ -292,137 +199,53 @@ export function ReleaseManager() {
<article
key={release.id}
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">
<h2>{release.title}</h2>
<p>
{release.versionLabel}
{release.releasedOn ? ` · ${release.releasedOn}` : ""}
</p>
<p>{release.publication.canonicalPath ?? "아직 공개하지 않음"}</p>
</div>
<dl>
<div>
<dt></dt>
<dd>{release.versionLabel}</dd>
</div>
<div>
<dt></dt>
<dd>{STATUS_LABELS[release.workflowStatus] ?? release.workflowStatus}</dd>
</div>
<div>
<dt></dt>
<dd>{release.releasedOn || "미정"}</dd>
</div>
</dl>
<button
className="studio-secondary-button"
type="button"
disabled={pending}
onClick={() => setSelectedId(selectedId === release.id ? null : release.id)}
>
{selectedId === release.id ? "닫기" : "편집"}
</button>
<button
className="studio-secondary-button"
type="button"
disabled={pending}
onClick={() => void remove(release)}
>
</button>
<div className="studio-row-actions">
<GuardedStudioLink
className="studio-secondary-button"
href={`/studio/releases/${release.id}`}
>
</GuardedStudioLink>
<button
className="studio-secondary-button"
type="button"
disabled={pending}
onClick={() => void remove(release)}
>
</button>
</div>
</article>
))}
</div>
</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>
);
}
@@ -4,6 +4,7 @@ import type { StudioDashboard as StudioDashboardData } from "../../../contracts/
import type { components } from "../../../contracts/studio/generated.ts";
import { useStudio } from "../use-studio.ts";
import { GuardedStudioLink } from "./guarded-studio-link.tsx";
import { HomeFocusEditor } from "./home-focus-editor.tsx";
type DocumentSummary = components["schemas"]["DocumentSummary"];
@@ -158,6 +159,7 @@ export function StudioDashboard() {
href="/studio/documents"
empty="게시 준비가 끝난 문서가 없습니다."
/>
<HomeFocusEditor />
<section className="studio-work-section">
<div className="studio-section-title">
<h2> </h2>
@@ -2,6 +2,7 @@ import { useCallback, useEffect, useState, type FormEvent } from "react";
import type { ProjectIndexItem, TopicEdit } from "../../../contracts/management/contract.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 { useStudio } from "../use-studio.ts";
@@ -15,6 +16,23 @@ import { useStudio } from "../use-studio.ts";
* <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() {
const { managementGateway, setRequestAnnouncement } = useStudio();
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 (
<div className="studio-page studio-documents-page">
<header className="studio-page-top">
@@ -269,27 +318,54 @@ export function TaxonomyManager() {
<article className="studio-document-row" key={project.id}>
<p className="studio-row-label">PROJECT</p>
<div className="studio-document-title">
<h2>{project.name}</h2>
<h2>
<GuardedStudioLink href={`/studio/projects/${project.id}`}>
{project.name}
</GuardedStudioLink>
</h2>
<p>{project.currentObjective ?? "목표 미지정"}</p>
</div>
<dl>
<div>
<dt></dt>
<dd>{project.phase}</dd>
<dd>{phaseLabel[project.phase] ?? project.phase}</dd>
</div>
<div>
<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>
</dl>
<button
className="studio-secondary-button"
type="button"
disabled={pending}
onClick={() => void removeProject(project)}
>
</button>
<div className="studio-row-actions">
<button
className="studio-secondary-button"
type="button"
disabled={pending}
onClick={() => void togglePublish(project)}
>
{project.targetVisibility === "PRIVATE" ? "게시" : "게시 취소"}
</button>
<button
className="studio-secondary-button"
type="button"
disabled={pending}
onClick={() => void removeProject(project)}
>
</button>
</div>
</article>
))}
</div>
@@ -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;
--code-canvas: #15181d;
--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
viewport bottom during loading and then dropped out of view when the content
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 > .ui-page {
/* Takes the slack so the footer stays put whether the route rendered a long
@@ -1329,11 +1430,18 @@ dialog::backdrop {
text-align: center;
}
/* 모달 dialog 를 화면 가운데에 둔다. UA 기본값(margin:auto)에 맡기면 이 빌드에서는 좌상단에
붙는다 — .search-dialog 와 Studio 의 .studio-unsaved-dialog 가 같은 이유로 position/inset/
margin 을 명시한다. 여기만 빠져 있어 "크게 보기" 가 왼쪽 위에 열렸다. */
.figure-dialog {
position: fixed;
inset: 0;
margin: auto;
width: min(1180px, calc(100% - 48px));
max-width: none;
max-height: calc(100dvh - 48px);
padding: 0;
overflow: hidden;
overflow: auto;
border: 1px solid var(--line-strong);
border-radius: 9px;
background: var(--paper);
@@ -2174,6 +2282,20 @@ dialog::backdrop {
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-document-header h1 {
margin: 15px 0 18px;
@@ -2297,7 +2419,7 @@ dialog::backdrop {
.reference-purpose,
.document-relations {
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 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 + 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;
}
@@ -36,6 +36,31 @@
.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 > 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-resolution-fields { min-width: 0; margin: 34px 0 0; padding: 0; border: 0; }
.studio-app .studio-ordered-list legend,
@@ -107,6 +107,15 @@
.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-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 h2 { margin: 0; font-size: 25px; }
.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 span,
.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 .studio-primary-button { margin: 0; }
.studio-app .studio-create-footer { display: flex; flex-wrap: wrap; align-items: center; gap: 12px 20px; margin-top: 28px; }
/*
`.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-primary-button:disabled { opacity: 0.55; cursor: wait; }
@@ -216,6 +216,13 @@ export const TECH_LOG_ROUTE_RUNTIME = Object.freeze({
"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",
routeModule(
@@ -223,6 +230,13 @@ export const TECH_LOG_ROUTE_RUNTIME = Object.freeze({
"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",
routeModule(
+13 -6
View File
@@ -87,11 +87,16 @@ const PLATFORM_KO_MESSAGES = {
"route.auth.integration.description":
"외부 인증 소유자가 연결되면 이 보호 라우트를 사용할 수 있습니다.",
"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.required.description":
"이 화면은 인증 연동 지점을 확인하기 위한 보호 라우트입니다.",
"기록을 쓰고 게시하려면 로그인이 필요합니다. 로그인하면 방금 열려던 화면으로 돌아옵니다.",
"route.documentTitle": "{title} · {appName}",
"chunk.checking": "새 릴리스 정보를 확인하고 있습니다.",
"chunk.reloadOnce": "새 버전으로 한 번만 전환합니다.",
@@ -271,11 +276,13 @@ const PLATFORM_EN_MESSAGES = {
"route.auth.integration.description":
"This protected route is available after an external authentication owner is connected.",
"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":
"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":
"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}",
"chunk.checking": "Checking the new release information.",
"chunk.reloadOnce": "Switching to the new version once.",
@@ -19,6 +19,7 @@ export type MessageParameters = Readonly<{
"action.alertCloseNamed": { title: string };
"route.loadingNamed": { title: string };
"route.documentTitle": { title: string; appName: string };
"route.auth.returnTo": { path: string };
"template.supportReference": { reference: string };
"form.remaining": { count: number };
"boot.supportReference": { reference: string };
+41 -28
View File
@@ -241,35 +241,48 @@ function ProtectedRoute({
);
}
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 (
<section className="ui-page" data-surface="authentication-required">
<PageHeader
title={
recovering
? message("route.auth.recovering.title")
: message("route.auth.required.title")
}
description={
recovering
? message("route.auth.recovering.description")
: message("route.auth.required.description")
}
/>
<Button
disabled={pending}
onClick={() => void continueSession()}
>
{pending
? message("common.processing")
: recovering
? message("action.recoverSession")
: message("action.signIn")}
</Button>
{failed ? (
<p className="ui-terminal-error" role="alert">
{message("shell.session.actionFailed")}
</p>
) : null}
<section className="ui-page auth-gate" data-surface="authentication-required">
<div className="auth-gate__card">
<PageHeader
eyebrow={message("route.auth.eyebrow")}
title={
recovering
? message("route.auth.recovering.title")
: message("route.auth.required.title")
}
description={
recovering
? message("route.auth.recovering.description")
: message("route.auth.required.description")
}
/>
<div className="auth-gate__actions">
<Button disabled={pending} onClick={() => void continueSession()}>
{pending
? message("common.processing")
: recovering
? message("action.recoverSession")
: message("action.signIn")}
</Button>
<p className="auth-gate__return">
{message("route.auth.returnTo", {
path: `${location.pathname}${location.search}`,
})}
</p>
</div>
{failed ? (
<p className="ui-terminal-error" role="alert">
{message("shell.session.actionFailed")}
</p>
) : null}
</div>
</section>
);
}
+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", () => {
const rejected = [
"<script>alert(1)</script>",
@@ -74,11 +104,12 @@ describe("Content Format v1", () => {
"[x](</safe\u001fpath>)",
"[x](</safe\u007fpath>)",
"- outer\n - nested",
"# level one",
"- [ ] task",
"> > nested",
':::unknown key="value"\ntext\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) {
@@ -18,7 +18,7 @@ test("vendored contract matches the recorded canonical digest", () => {
test("canonical source records the pinned revision and version", () => {
assert.equal(canonicalSource.packageId, "@tech-log/studio-contract");
assert.equal(canonicalSource.version, "3.0.0");
assert.equal(canonicalSource.version, "3.1.0");
// revision은 생성 시점에 기록된다. canonical 저장소는 활발히 편집 중이므로
// 특정 값을 박아두면 계약이 그대로인데도 테스트가 깨진다. 형식만 고정한다.
assert.match(canonicalSource.sourceRevision, /^[0-9a-f]{7,64}$/);
@@ -14,7 +14,10 @@ import {
derivePreviewState,
deriveValidationState,
} from "../../../src/features/tech-log/domain/studio/document-state.ts";
import { createLocalId } from "../../../src/features/tech-log/domain/studio/local-id.ts";
import {
createLocalId,
createNewItemId,
} from "../../../src/features/tech-log/domain/studio/local-id.ts";
const now = "2026-08-14T12:00:00.000Z";
const validUntil = "2026-08-14T12:30:00.000Z";
@@ -317,3 +320,32 @@ test("local Studio IDs are deterministic without Web Crypto and prefer UUIDs whe
"save-fixture-uuid",
);
});
/*
.
·· id `createLocalId` , `relation-<uuid>`
. uuid 400
"saveStudioDocument broke its contract."
. .
*/
const UUID_SHAPE = /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/;
test("new list item IDs are bare UUIDs, because they cross the contract", () => {
assert.equal(
createNewItemId({ randomUUID: () => "3f1a2b4c-5d6e-4f70-8a91-b2c3d4e5f607" }),
"3f1a2b4c-5d6e-4f70-8a91-b2c3d4e5f607",
);
// Web Crypto 가 없는 환경에서도 형식은 uuid 여야 한다 — 값은 서버가 새로 부여하지만, 형식이
// 어긋나면 요청 자체가 거절된다.
assert.match(createNewItemId(null, () => 0.5), UUID_SHAPE);
assert.match(createNewItemId(null, () => 0), UUID_SHAPE);
assert.match(createNewItemId(null, () => 0.999), UUID_SHAPE);
});
test("prefixed local IDs never satisfy the contract's UUID shape", () => {
assert.equal(
UUID_SHAPE.test(createLocalId("relation", { randomUUID: () => "3f1a2b4c-5d6e-4f70-8a91-b2c3d4e5f607" })),
false,
);
});
+40 -3
View File
@@ -802,8 +802,10 @@ describe("candidate archive and provider upload boundaries", () => {
// shorter than one `systemctl show`, so the tree has to be sampled, not
// sampled once at whatever moment the assertions happen to arrive.
const tree = recordProviderProcessTree(execution.child.pid!);
const unit = await waitForProviderUnit("vulnerability", execution.child.pid);
const unit = await waitForProviderUnit("vulnerability", execution.child.pid, tree.sample);
tree.sample();
const properties = showProviderUnit(unit);
tree.sample();
expect(properties).toMatchObject({
ActiveState: "active",
CPUQuotaPerSecUSec: "1s",
@@ -827,7 +829,22 @@ describe("candidate archive and provider upload boundaries", () => {
const supervisorArguments = showProcessArguments(execution.child.pid);
expect(supervisorArguments).not.toMatch(new RegExp(`${command}|${credential}`, "u"));
const reportIdentity = await lstat(fixture.reportPath);
const result = await execution.completion;
/*
. 5ms
1.5
`directChildArguments` .
.
*/
const result = await (async () => {
let settled: Awaited<typeof execution.completion> | undefined;
const completion = execution.completion.then((value) => (settled = value));
while (settled === undefined) {
tree.sample();
await Promise.race([completion, delay(5)]);
}
tree.sample();
return settled;
})();
tree.stop();
expect(result.code, result.stderr).toBe(0);
expect(result.stdout).not.toContain(credential);
@@ -849,7 +866,16 @@ describe("candidate archive and provider upload boundaries", () => {
expect(observedArguments.join("\n")).not.toContain(credential);
expect(directChildArguments.filter((arguments_) => arguments_.includes("/usr/bin/systemd-run"))).toHaveLength(1);
expect(directChildArguments.filter((arguments_) => arguments_.includes("provider-raw-guardian.ts"))).toHaveLength(1);
expect(processArguments.filter((arguments_) => arguments_.includes("provider-scope-wrapper.ts"))).toHaveLength(1);
/*
scope "몇 개인가" argv pid fork
, bwrap/prlimit .
(systemd-run 1, raw-guardian 1)
. wrapper scope .
*/
expect(
processArguments.filter((arguments_) => arguments_.includes("provider-scope-wrapper.ts")).length,
`scope wrapper never observed in the scope; recorded: ${processArguments.join(" | ")}`,
).toBeGreaterThan(0);
expect(
processArguments.filter((arguments_) => /\/usr\/bin\/(?:bwrap|prlimit)/u.test(arguments_)).length,
`sandbox never observed in the scope; recorded: ${processArguments.join(" | ")}`,
@@ -1722,11 +1748,15 @@ function providerSupervisorEnvironment(
async function waitForProviderUnit(
kind: "vulnerability" | "provenance",
supervisorPid: number | undefined,
/** 블로킹 조회 앞뒤로 부른다 — 그 사이에만 살아 있는 프로세스를 놓치지 않기 위한 것이다. */
onPoll?: () => void,
): Promise<string> {
if (!supervisorPid) throw new Error("provider supervisor did not expose its PID");
const pattern = `ca-provider-${kind}-${supervisorPid}-*.scope`;
for (let attempt = 0; attempt < 120; attempt += 1) {
onPoll?.();
const units = listProviderUnits(pattern);
onPoll?.();
if (units.length === 1) return units[0]!;
if (units.length > 1) throw new Error(`provider unit identity is ambiguous: ${units.join(", ")}`);
await delay(25);
@@ -2025,6 +2055,13 @@ function recordProviderProcessTree(supervisorPid: number) {
timer.unref();
sample();
return Object.freeze({
/*
. `systemctl` `spawnSync` ,
5ms bubblewrap ,
.
.
*/
sample,
stop() {
stopped = true;
clearInterval(timer);
+2 -2
View File
@@ -200,8 +200,8 @@ describe("CI gate contract", () => {
// than taking either side's number.
// Task 11 added TECH_LOG_STUDIO_ASSETS's manual accessibility evidence.
// Alignment follow-up item 2 added the TechLog junit report.
expect(contract.gates.reduce((total, gate) => total + gate.evidenceArtifactIds.length, 0)).toBe(111);
expect(contract.artifacts).toHaveLength(132);
expect(contract.gates.reduce((total, gate) => total + gate.evidenceArtifactIds.length, 0)).toBe(113);
expect(contract.artifacts).toHaveLength(134);
expect(contract.stages).toHaveLength(5);
expect(contract.retention.classes).toHaveLength(5);
expect(index.gates.get("FE-GATE-015")?.commandIds).toHaveLength(2);
@@ -192,7 +192,7 @@ describe("selective Task 3 contract closure", () => {
expect(canonical.gates).toHaveLength(27);
expect(canonical.commands).toHaveLength(85);
expect(canonical.gates.reduce((sum, gate) => sum + gate.commandIds.length, 0)).toBe(97);
expect(canonical.artifacts).toHaveLength(132);
expect(canonical.artifacts).toHaveLength(134);
expect(canonical.stages).toHaveLength(5);
expect(canonical.retention.classes).toHaveLength(5);
@@ -48,9 +48,11 @@ describe("TechLog production serving contract", () => {
"^/studio/documents/[^/]+/publish$",
"^/studio/documents/[^/]+/validation$",
"^/studio/documents/new$",
"^/studio/projects/[^/]+$",
"^/studio/publications$",
"^/studio/publications/[^/]+/preview$",
"^/studio/releases$",
"^/studio/releases/[^/]+$",
"^/studio/taxonomy$",
]);
expect(contract.notFound).toEqual({
+2
View File
@@ -32,7 +32,9 @@ const techLogRouteChunks = Object.freeze({
"/studio/pages/publication-preview-page.tsx": "route-tech-log-studio-publication-preview",
"/studio/pages/assets-page.tsx": "route-tech-log-studio-assets",
"/studio/pages/taxonomy-page.tsx": "route-tech-log-studio-taxonomy",
"/studio/pages/project-edit-page.tsx": "route-tech-log-studio-project-edit",
"/studio/pages/releases-page.tsx": "route-tech-log-studio-releases",
"/studio/pages/release-edit-page.tsx": "route-tech-log-studio-release-edit",
"/studio/pages/studio-not-found-page.tsx": "route-tech-log-studio-not-found",
"/public/pages/public-not-found-page.tsx": "route-not-found",
} as const);