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

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

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

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

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

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0189NzCryfeqDzS81EWidnBx
2026-08-26 23:44:20 +09:00
DongHyeonkaandClaude Opus 5 ad8f322b43 fix: 질문 머리말이 관계에 담긴 프로젝트를 읽는다
질문 상세는 프로젝트를 `question` 이 아니라 `relations.primaryProject` 에 담는다 —
Case/Reference 와 다른 자리다. `question` 에서 찾고 있었으므로 머리말의 프로젝트 칸이 늘
비어 있었다.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XEHXspz4rv5pB5wiiSsVDu
2026-08-24 15:33:43 +09:00
43 changed files with 917 additions and 579 deletions
+6 -6
View File
@@ -38,28 +38,28 @@
},
"contractSet": {
"setAlgorithm": "CA_CONTRACT_SET_V1",
"setDigest": "sha256:b729150c56b64ba01d7a71a7442c14bb04d9e573b852d1d1879e748854c20174",
"setDigest": "sha256:cdcfb628a502d71596f1162726eb395aad0f5f92cf05fd77d304f8e51c81b2fc",
"packages": [
{
"packageId": "@tech-log/management-contract",
"version": "1.0.0",
"digest": "sha256:72650735061fde627f5037571eb986cb758f44a546f065c88408399f8eec4a55",
"runtimeProtocolVersion": 1,
"sourceRevision": "06ae075"
"sourceRevision": "ef49d3a"
},
{
"packageId": "@tech-log/public-contract",
"version": "2.1.0",
"digest": "sha256:702d6666a8feba9899c7eb7c2a94a0880bcb23b178c7ed2009c6e69d9a1c848c",
"digest": "sha256:7eb668e39e279e49767306dd36e1dd51302071c39d78495d21307bbd9676220e",
"runtimeProtocolVersion": 1,
"sourceRevision": "06ae075"
"sourceRevision": "ef49d3a"
},
{
"packageId": "@tech-log/studio-contract",
"version": "3.1.0",
"digest": "sha256:6fc015ca6727af88b7fb0088e02ba97846e1dd79fb0d4fc593cc79f2a3b9795f",
"digest": "sha256:18dd46898be64b07f7e826409d19347512613ee2e22420028a4a0644f50f37dd",
"runtimeProtocolVersion": 1,
"sourceRevision": "06ae075"
"sourceRevision": "ef49d3a"
}
]
}
@@ -20,12 +20,12 @@ import {
decisionItemToDecision,
flattenRelations,
knowledgeListItemToRecord,
markdownLines,
markdownSections,
questionListItemToRecord,
releaseDetailToRelease,
searchItemToEntity,
} from "./public-content-mapping.ts";
import type { components } from "../../contracts/public/generated.ts";
import type { StudioOperationExecutor } from "./http-studio-gateway.ts";
const ROUTE_ID = "TECH_LOG_PUBLIC";
@@ -142,13 +142,19 @@ export function createHttpPublicContentGateway(
const canonicalPath = String(detail.canonicalPath ?? "");
const groups = (detail.relations as Readonly<Record<string, never>>) ?? {};
const projectOf = (entry: Readonly<{ title?: string; path?: string }> | undefined) =>
entry?.path
? { name: entry.title ?? "", slug: entry.path.split("/").filter(Boolean).pop() ?? "", path: entry.path }
: undefined;
if (kind === "CASE") {
const body = (detail.case as Readonly<Record<string, unknown>>) ?? {};
return Object.freeze({
...baseOf("CASE", slug, {
title: body.title as string,
summary: body.problemSummary as string,
// 제목 바로 아래에 오는 것은 문서의 요약이다. 유형별 요약(문제/범위)을 쓰면 바로 아래
// 블록과 같은 글을 두 번 말한다.
summary: (body.summary as string) ?? "",
path: canonicalPath,
primaryTopic: body.primaryTopic as never,
primaryProject: body.primaryProject as never,
@@ -193,7 +199,7 @@ export function createHttpPublicContentGateway(
return Object.freeze({
...baseOf("REFERENCE", slug, {
title: body.title as string,
summary: body.purposeSummary as string,
summary: (body.summary as string) ?? "",
path: canonicalPath,
primaryTopic: body.primaryTopic as never,
primaryProject: body.primaryProject as never,
@@ -205,48 +211,90 @@ export function createHttpPublicContentGateway(
}),
}),
kind: "REFERENCE",
purpose: (body.purposeSummary as string) ?? "",
/*
여기서 읽는 이름은 계약이 실제로 주는 이름이어야 한다. 한때 `purposeSummary`,
`applyWhenMarkdown`, `exceptionsMarkdown`, `examplesMarkdown` 을 읽었는데 계약에는 그런
칸이 없다 — 전부 undefined 로 떨어져 공개 Reference 화면이 통째로 비었다. Studio 에서는
같은 글이 다 보이므로 "공개 쪽만 안 나온다" 로 드러났다.
규칙과 예시는 `content` 마크다운을 잘라 만드는 것이 아니라 계약이 구조로 준다. Studio 의
편집기가 제목과 본문을 따로 받기 때문이다.
*/
purpose: (body.scopeSummary as string) ?? "",
rules: Object.freeze(
markdownSections(body.content as string).map((section) => ({
title: section.title,
body: section.paragraphs.join("\n"),
})),
((body.rules as readonly Readonly<Record<string, unknown>>[] | undefined) ?? []).map(
(rule) => ({
title: String(rule.title ?? ""),
body: String(rule.body ?? ""),
}),
),
),
applyWhen: Object.freeze(markdownLines(body.applyWhenMarkdown as string)),
exceptions: Object.freeze(markdownLines(body.exceptionsMarkdown as string)),
examples: Object.freeze(markdownLines(body.examplesMarkdown as string)),
applyWhen: Object.freeze(((body.appliesTo as readonly string[] | undefined) ?? []).map(String)),
exceptions: Object.freeze(
((body.excludedScope as readonly string[] | undefined) ?? []).map(String),
),
examples: Object.freeze(((body.examples as readonly string[] | undefined) ?? []).map(String)),
verifiedAt: dateLabel(body.lastVerifiedAt as string),
}) as unknown as Extract<PublicRecord, { kind: K }>;
}
const body = (detail.question as Readonly<Record<string, unknown>>) ?? {};
const points = (body.points as readonly Readonly<Record<string, unknown>>[] | undefined) ?? [];
const pointsOf = (group: string) =>
Object.freeze(
points
.filter((point) => point.group === group)
.flatMap((point) => (point.items as readonly string[] | undefined) ?? []),
);
/*
`points` 는 그룹 이름을 키로 갖는 객체다 — 계약의 `QuestionPointGroup`. 여기서는
`{group, items}` 배열로 읽으면서 `.filter` 를 불렀고, 객체에는 그런 것이 없으니 상세
화면이 통째로 「요청을 처리하지 못했습니다」가 됐다. 목록은 이 칸을 비워 두고 만들기
때문에 탐색에서는 멀쩡히 보였고, 그래서 "게시했는데 안 뜬다" 로만 드러났다.
`as` 캐스트가 그 어긋남을 타입 검사에서 가렸다. 계약의 타입을 그대로 쓰면 다음에 모양이
바뀔 때 컴파일이 먼저 막는다.
*/
type QuestionPoints = components["schemas"]["QuestionPointGroup"];
const points = body.points as QuestionPoints | undefined;
const pointsOf = (group: keyof QuestionPoints) =>
Object.freeze([...(points?.[group] ?? [])].map(String));
return Object.freeze({
...baseOf("QUESTION", slug, {
title: body.question as string,
summary: body.summary as string,
path: canonicalPath,
primaryTopic: body.primaryTopic as never,
primaryProject: body.primaryProject as never,
/*
질문 상세는 프로젝트를 `question` 이 아니라 `relations.primaryProject` 에 담는다 —
Case/Reference 와 다른 자리다. `question` 에서 찾고 있었으므로 머리말의 프로젝트
칸이 늘 비어 있었다.
그 자리의 값은 `RelatedEntry` 라 `title`/`path` 를 쓴다. 머리말이 기다리는 것은
`name`/`slug` 이므로 여기서 옮겨 준다 — slug 는 경로의 마지막 마디다.
*/
primaryProject: projectOf(groups.primaryProject) as never,
publishedAt: body.updatedAt as string,
relations: flattenRelations(groups, {
derivedCases: "이 질문에서 나온 기록",
projectDecisions: "이 질문이 이끈 결정",
relatedQuestions: "관련 질문",
}),
/*
계약이 주는 이름은 `resultCase` / `producedDecision` / `derivedReferences` 다.
여기서는 `derivedCases` / `projectDecisions` / `relatedQuestions` 를 찾고 있었고,
하나도 맞지 않아 이유 자리에 영문 키가 그대로 나왔다.
`primaryProject` 는 관계가 아니라 이 질문이 속한 프로젝트다 — 머리말이 이미
보여 주므로 관계 목록에 넣지 않는다.
*/
relations: flattenRelations(
{
resultCase: groups.resultCase,
producedDecision: groups.producedDecision,
derivedReferences: groups.derivedReferences,
},
{
resultCase: "이 질문에서 나온 기록",
producedDecision: "이 질문이 이끈 결정",
derivedReferences: "이 질문에서 정리된 기준",
},
),
}),
kind: "QUESTION",
questionStatus: (body.status as QuestionRecord["questionStatus"]) ?? "OPEN",
facts: pointsOf("KNOWN_FACT"),
assumptions: pointsOf("ASSUMPTION"),
unknowns: pointsOf("UNRESOLVED"),
constraints: pointsOf("CONSTRAINT"),
facts: pointsOf("facts"),
assumptions: pointsOf("assumptions"),
unknowns: pointsOf("unknowns"),
constraints: pointsOf("constraints"),
options: Object.freeze([]),
nextValidation: (body.nextVerification as string) ?? "",
}) as unknown as Extract<PublicRecord, { kind: K }>;
@@ -54,6 +54,7 @@ export function listRecords(filters: RecordFilters = {}): PublicRecord[] {
.filter(
(record) =>
!hasTopicFilter ||
record.topicSlug.toLocaleLowerCase("ko-KR") === requestedTopic ||
record.topic.toLocaleLowerCase("ko-KR") === requestedTopic,
)
.filter(
@@ -142,17 +143,12 @@ export function getLatestEntries(): LatestRecordEntry[] {
};
}),
);
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) =>
/*
릴리스는 넣지 않는다. 이 목록은 서버의 `latestEntries` 와 같은 의미여야 하고, 그쪽은 공개
투영에서 고르므로 릴리스가 없다 — 릴리스는 Publication 파이프라인을 거치지 않는다. 홈 화면이
릴리스를 따로 읽어 합치므로, 여기서도 넣으면 같은 릴리스가 두 번 나온다.
*/
return [...activities].sort((left, right) =>
right.publishedAt.localeCompare(left.publishedAt),
);
}
@@ -173,7 +173,7 @@ export type PublicTopic = {
*/
export type LatestRecordEntry = {
id: string;
entryType: "CASE" | "REFERENCE" | "PROJECT_ACTIVITY" | "RELEASE";
entryType: "CASE" | "REFERENCE" | "QUESTION" | "PROJECT_ACTIVITY" | "RELEASE";
title: string;
summary: string;
path: string;
@@ -2,7 +2,7 @@
"packageId": "@tech-log/management-contract",
"version": "1.0.0",
"digest": "sha256:72650735061fde627f5037571eb986cb758f44a546f065c88408399f8eec4a55",
"sourceRevision": "06ae075",
"sourceRevision": "ef49d3a",
"operationIds": [
"createCaseDraft",
"getCaseForEdit",
@@ -1,8 +1,8 @@
{
"packageId": "@tech-log/public-contract",
"version": "2.1.0",
"digest": "sha256:702d6666a8feba9899c7eb7c2a94a0880bcb23b178c7ed2009c6e69d9a1c848c",
"sourceRevision": "06ae075",
"digest": "sha256:7eb668e39e279e49767306dd36e1dd51302071c39d78495d21307bbd9676220e",
"sourceRevision": "ef49d3a",
"operationIds": [
"getPublicSite",
"getPublicHome",
@@ -454,7 +454,7 @@ export interface components {
};
LatestEntry: {
/** @enum {string} */
entryType: "CASE" | "REFERENCE" | "PROJECT_ACTIVITY" | "RELEASE";
entryType: "CASE" | "REFERENCE" | "QUESTION" | "PROJECT_ACTIVITY" | "RELEASE";
title: string;
summary: string;
path: string;
@@ -536,6 +536,7 @@ export interface components {
indexable: boolean;
case: {
title: string;
summary?: string;
problemSummary: string;
conclusionSummary: string;
environmentSummary?: string[];
@@ -571,9 +572,15 @@ export interface components {
indexable: boolean;
reference: {
title: string;
summary?: string;
scopeSummary: string;
appliesTo: string[];
excludedScope: string[];
rules?: {
title: string;
body: string;
}[];
examples?: string[];
/** @enum {string} */
freshnessStatus: "CURRENT" | "REVIEW_DUE" | "HISTORICAL";
content: string;
@@ -1022,6 +1022,7 @@ components:
enum:
- CASE
- REFERENCE
- QUESTION
- PROJECT_ACTIVITY
- RELEASE
title:
@@ -1239,6 +1240,12 @@ components:
properties:
title:
type: string
# 문서가 스스로 밝히는 한 줄 요약이다. 제목 바로 아래에 온다.
#
# 이 자리가 없어서 화면은 problemSummary / scopeSummary 를 대신 썼고, 그러면 머리말이
# 바로 아래의 "문제" 나 "이 기준을 쓰는 이유" 와 같은 글을 두 번 말한다.
summary:
type: string
problemSummary:
type: string
conclusionSummary:
@@ -1325,6 +1332,12 @@ components:
properties:
title:
type: string
# 문서가 스스로 밝히는 한 줄 요약이다. 제목 바로 아래에 온다.
#
# 이 자리가 없어서 화면은 problemSummary / scopeSummary 를 대신 썼고, 그러면 머리말이
# 바로 아래의 "문제" 나 "이 기준을 쓰는 이유" 와 같은 글을 두 번 말한다.
summary:
type: string
scopeSummary:
type: string
appliesTo:
@@ -1335,6 +1348,23 @@ components:
type: array
items:
type: string
# Reference 의 본문은 `content` 마크다운이 아니라 이 두 칸에 있다. Studio 의 Reference
# 편집기는 규칙(제목+본문)과 예시를 따로 받고 body_markdown 은 비워 두므로, 이것을
# 내보내지 않으면 공개 화면에 판단 기준과 예시가 통째로 빠진다.
rules:
type: array
items:
type: object
required: [title, body]
properties:
title:
type: string
body:
type: string
examples:
type: array
items:
type: string
freshnessStatus:
type: string
enum:
@@ -1,8 +1,8 @@
{
"packageId": "@tech-log/studio-contract",
"version": "3.1.0",
"digest": "sha256:6fc015ca6727af88b7fb0088e02ba97846e1dd79fb0d4fc593cc79f2a3b9795f",
"sourceRevision": "06ae075",
"digest": "sha256:18dd46898be64b07f7e826409d19347512613ee2e22420028a4a0644f50f37dd",
"sourceRevision": "ef49d3a",
"operationIds": [
"getStudioSession",
"getStudioDashboard",
@@ -1167,7 +1167,7 @@ export interface components {
/** @enum {string} */
status: "PROPOSED" | "ADOPTED";
/** Format: date */
decidedOn: string;
decidedOn: string | null;
statement: string;
rationale: string;
consequences: components["schemas"]["OrderedText"][];
@@ -1572,7 +1572,10 @@ components:
properties:
kind: { type: string, enum: [PROJECT_DECISION] }
status: { type: string, enum: [PROPOSED, ADOPTED] }
decidedOn: { type: string, format: date }
# 결정일은 비어 있을 수 있다. 검증은 이것을 경고로만 다루므로(DECIDED_ON_REQUIRED)
# 날짜 없이 게시할 수 있는데, 렌더 모델이 필수로 요구하면 그 문서는 미리보기조차
# 열리지 않는다 — 두 규칙이 어긋나면 작성자는 "경고라며 왜 안 되냐"를 만난다.
decidedOn: { type: [string, "null"], format: date }
statement: { type: string, maxLength: 100000 }
rationale: { type: string, maxLength: 100000 }
consequences: { type: array, maxItems: 50, items: { $ref: "#/components/schemas/OrderedText" } }
@@ -131,7 +131,18 @@ function renderContext(
function publicPath(input: WorkingCopyInput, project: CatalogEntry | null) {
if (input.kind === "PROJECT_DECISION") {
if (!project?.publicPath) fail("PROJECT public path is required");
/*
Decision 의 공개 주소는 자기 slug 가 아니라 프로젝트 주소 아래에 있다. 그래서 프로젝트가
공개되어 있지 않으면 이 문서에는 아직 주소가 없다.
예전 문구는 "PROJECT public path is required" 였다. 사실이지만 작성자가 할 일을 말해 주지
않는다 — 무엇을 어디서 눌러야 하는지 적는다.
*/
if (!project?.publicPath) {
fail(
"이 Decision 의 공개 주소는 프로젝트 주소 아래에 있습니다. 주제·프로젝트 화면에서 이 프로젝트를 먼저 게시해 주세요.",
);
}
return `${project.publicPath}/decisions#${input.slug}`;
}
const prefix =
@@ -186,7 +197,7 @@ export function projectWorkingCopy(
const project = catalogEntry(catalog, input.projectId, "PROJECT", false);
if (!topic) fail("TOPIC catalog entry is required");
if (input.kind === "PROJECT_DECISION" && !project) {
fail("PROJECT catalog entry is required");
fail("Decision 은 프로젝트에 속합니다. 기본 정보에서 프로젝트를 골라 주세요.");
}
const base = {
@@ -273,12 +284,16 @@ export function projectWorkingCopy(
case "PROJECT_DECISION":
if (!input.decisionStatus) fail("Decision status is required");
if (!input.decidedOn) fail("Decision date is required");
/*
결정일은 비어 있어도 모델을 만든다. 검증이 그것을 경고로만 다루므로 날짜 없이 게시할 수
있는데, 여기서 막으면 그 문서는 미리보기조차 열리지 않는다 — 작성자는 "경고라면서 왜
안 되냐"를 만난다. 화면이 "미정"이라고 말하면 된다.
*/
return {
...base,
kind: "PROJECT_DECISION",
status: input.decisionStatus,
decidedOn: input.decidedOn,
decidedOn: input.decidedOn ?? null,
statement: input.statement,
rationale: input.rationale,
consequences: ordered(input.consequences),
@@ -37,7 +37,18 @@ export function ExploreFilterForm({
);
const resolved = await Promise.all(projectSlugs.map((slug) => queries.getProject(slug)));
return {
topics: [...new Set(records.map((record) => record.topic))].sort(),
// 주제는 이름이 아니라 slug 로 거른다 — 프로젝트와 같다. 이름을 실었을 때는
// `topic=OAuth/OIDC 인증 경계` 가 나갔고, slug 로 거르는 API 는 0건을 돌려줬다.
// 화면에 보일 이름과 보낼 slug 가 다르므로 짝으로 들고 있어야 한다.
topics: [
...new Map(
records
.filter((record) => record.topicSlug)
.map((record) => [record.topicSlug, record.topic] as const),
),
]
.map(([slug, name]) => ({ slug, name }))
.sort((left, right) => left.name.localeCompare(right.name, "ko-KR")),
projects: resolved
.filter((item) => item !== undefined)
.map((item) => ({ slug: item.slug, title: item.title })),
@@ -47,8 +58,10 @@ export function ExploreFilterForm({
const projects = view.data?.projects ?? [];
const normalizedTopic = topic?.toLocaleLowerCase("ko-KR");
const selectedTopic = topics.find(
(item) => item.toLocaleLowerCase("ko-KR") === normalizedTopic,
);
(item) =>
item.slug.toLocaleLowerCase("ko-KR") === normalizedTopic ||
item.name.toLocaleLowerCase("ko-KR") === normalizedTopic,
)?.slug;
const normalizedProject = project?.toLocaleLowerCase("ko-KR");
const selectedProject = projects.find(
(item) =>
@@ -67,7 +80,7 @@ export function ExploreFilterForm({
const data = new FormData(event.currentTarget);
const search = new URLSearchParams();
for (const [key, value] of data) {
if (typeof value === "string") search.append(key, value);
if (typeof value === "string" && value !== "") search.append(key, value);
}
void navigate(`${action}?${search.toString()}`);
}
@@ -83,7 +96,7 @@ export function ExploreFilterForm({
{showType ? (
<label><span></span><select name="type" defaultValue={kind ?? ""}><option value=""></option><option value="CASE">Case</option><option value="REFERENCE">Reference</option><option value="QUESTION">Open Question</option></select></label>
) : null}
<label><span></span><select name="topic" defaultValue={selectedTopic ?? ""}><option value=""></option>{topics.map((item) => <option key={item}>{item}</option>)}</select></label>
<label><span></span><select name="topic" defaultValue={selectedTopic ?? ""}><option value=""></option>{topics.map((item) => <option value={item.slug} key={item.slug}>{item.name}</option>)}</select></label>
<label><span></span><select name="project" defaultValue={selectedProject ?? ""}><option value=""></option>{projects.map((item) => <option value={item.slug} key={item.slug}>{item.title}</option>)}</select></label>
<button type="submit"></button>
{hasActiveFilter ? <Link to={action}> </Link> : null}
@@ -19,7 +19,13 @@ export function PublicDocumentHeader({ record }: { record: PublicRecord }) {
{kindLabels[record.kind]}
</Link>
<span aria-hidden="true">/</span>
<Link to={`/topics/${record.topicSlug}`}>{record.topic}</Link>
{/*
주제 페이지가 아니라 그 주제로 거른 탐색으로 보낸다. `/topics/:slug` 는 세 개를
하드코딩해 두고 있어 실제 주제는 무엇이든 404 가 되고, 설명·범위·대표 기록이 전부
비어 있어 지금 채울 내용도 없다. 독자가 여기서 기대하는 것 — 같은 주제의 기록 목록 —
은 탐색 필터가 그대로 준다.
*/}
<Link to={`/explore?topic=${encodeURIComponent(record.topicSlug)}`}>{record.topic}</Link>
<span aria-hidden="true">/</span>
<Link to={`/projects/${record.projectSlug}`}>
{record.projectTitle}
@@ -57,7 +63,9 @@ export function publicRenderModelBase(
topic: {
id: `topic-${record.topicSlug}`,
label: record.topic,
publicPath: `/topics/${record.topicSlug}`,
// 공개 문서의 머리말이 실제로 그리는 주제 링크는 이 값이다 — 위의 breadcrumb 과 같은
// 이유로 탐색 필터를 가리킨다. 둘 중 하나만 고치면 화면에서는 그대로 404 로 간다.
publicPath: `/explore?topic=${encodeURIComponent(record.topicSlug)}`,
},
project: {
id: `project-${record.projectSlug}`,
@@ -50,6 +50,11 @@ function optionalString(value: unknown): string | undefined {
* 릴리스는 Publication 파이프라인을 거치지 않아 그 투영에 행이 없다. 그래서 릴리스만 따로 읽어
* 시간순으로 합친다.
*/
const latestTypeLabels: Readonly<Record<string, string>> = {
PROJECT_ACTIVITY: "PROJECT ACTIVITY",
QUESTION: "OPEN QUESTION",
};
async function getLatestEntries(
publicContent: PublicContentQueries,
): Promise<LatestEntry[]> {
@@ -60,7 +65,8 @@ async function getLatestEntries(
const recordTimeline: LatestEntry[] = records.map((entry) => ({
id: entry.id,
typeLabel: entry.entryType === "PROJECT_ACTIVITY" ? "PROJECT ACTIVITY" : entry.entryType,
// 목록의 다른 이름들과 같은 자리에 놓이므로 표기도 같은 규칙을 쓴다 — 대문자에 공백.
typeLabel: latestTypeLabels[entry.entryType] ?? entry.entryType,
title: entry.title,
summary: entry.summary,
date: dateLabel(entry.publishedAt),
@@ -1,5 +1,3 @@
import { Link } from "react-router-dom";
import {
RegisteredNotFoundRoute,
useRouteInput,
@@ -21,28 +19,33 @@ export function ProjectActivityPage() {
const { project, activity } = view.data;
if (!project) return <RegisteredNotFoundRoute />;
/*
활동은 로그다 — 언제 무엇을 올렸는지만 적는다.
예전에는 각 줄에 그 기록으로 가는 링크가 있었다. 그러면 같은 글에 닿는 길이 둘이 되고,
읽는 사람은 "기록"과 "활동"이 어떻게 다른지 매번 다시 판단해야 한다. 글을 읽는 자리는
기록 화면 하나로 둔다.
*/
return (
<main id="main-content" className="shell project-page">
<ProjectPageHeader project={project} title={`${project.title} 활동`} />
<ol className="project-activity-list">
{activity.map((item) => (
<li key={item.id}>
<article id={item.id}>
<div>
<span>{item.type}</span>
<time dateTime={item.dateTime}>{item.date}</time>
</div>
<h2>{item.title}</h2>
<p>{item.summary}</p>
<Link to={item.recordPath ?? item.path}>
{item.recordPath
? "연결된 공개 기록 읽기"
: "이 활동 위치 열기"}
</Link>
</article>
</li>
))}
</ol>
{activity.length === 0 ? (
<p className="public-empty-note"> .</p>
) : (
<ol className="project-activity-list">
{activity.map((item) => (
<li key={item.id}>
<article id={item.id}>
<div>
<span>{item.type}</span>
<time dateTime={item.dateTime}>{item.date}</time>
</div>
<h2>{item.title}</h2>
</article>
</li>
))}
</ol>
)}
</main>
);
}
@@ -38,10 +38,11 @@ export function TopicPage() {
const topic = topicConfig(params.slug);
// Hooks run unconditionally, so the unknown-topic case is handled by the
// loader and the not-found route is chosen after it.
const slug = typeof params.slug === "string" ? params.slug : "";
const view = usePublicContent(
["tech-log", "topic", topic?.title],
["tech-log", "topic", slug],
async (queries) =>
topic ? { records: await queries.listRecords({ topic: topic.title }) } : { records: [] },
topic ? { records: await queries.listRecords({ topic: slug }) } : { records: [] },
);
if (!topic) return <RegisteredNotFoundRoute />;
if (!view.ready) return view.fallback;
@@ -16,6 +16,16 @@ export function DocumentToc({ headings, variant }: DocumentTocProps) {
const detailsRef = useRef<HTMLDetailsElement>(null);
useEffect(() => {
/*
`IntersectionObserver` 가 없는 환경이 있다 — jsdom 이 그렇고, 오래된 브라우저도 그렇다.
없으면 "지금 읽는 절" 표시만 못 할 뿐 목차 자체는 쓸 수 있으므로, 없다고 문서를 통째로
못 그리게 두지 않는다.
한동안 이 컴포넌트는 픽스처 Case 하나에서만 쓰여 그 환경을 만난 적이 없었다. 모든 Case 가
목차를 받게 되면서 드러났다.
*/
if (typeof IntersectionObserver === "undefined") return undefined;
const elements = headings
.map((heading) => document.getElementById(heading.id))
.filter((element): element is HTMLElement => Boolean(element));
@@ -435,7 +435,11 @@ function ProjectDecisionDocument({
<header>
<div>
<span>{model.status}</span>
<time dateTime={model.decidedOn}>{displayDate(model.decidedOn)}</time>
{model.decidedOn ? (
<time dateTime={model.decidedOn}>{displayDate(model.decidedOn)}</time>
) : (
<span> </span>
)}
</div>
<h2>{model.title}</h2>
<p><PlainText text={model.statement} /></p>
@@ -102,7 +102,7 @@ export function CaseFields({
<label className="studio-field"><span> </span><input type="date" value={draft.lastVerifiedOn ?? ""} onChange={(event) => update({ lastVerifiedOn: event.currentTarget.value || null })} /><FieldNotice issues={issues} path="/lastVerifiedOn" /></label>
<label className="studio-field studio-field--wide"><span> Markdown</span><textarea ref={bodyRef} className="studio-markdown-field" value={draft.bodyMarkdown} onChange={(event) => update({ bodyMarkdown: event.currentTarget.value })} /><FieldNotice issues={issues} path="/bodyMarkdown" /></label>
</div>
<div className="studio-asset-panel" aria-labelledby="studio-asset-panel-title">
<div className="studio-asset-panel" role="group" aria-labelledby="studio-asset-panel-title">
<p className="studio-eyebrow">EVIDENCE</p>
<h3 id="studio-asset-panel-title"> Asset </h3>
<p> evidence . READY Asset만 .</p>
@@ -15,6 +15,7 @@ import { DocumentEditor } from "./document-editor.tsx";
import { GuardedStudioLink } from "./guarded-studio-link.tsx";
import { deriveValidationState } from "../../../domain/studio/document-state.ts";
import { slugFromName } from "./slug-from-name.ts";
import { useSaveShortcut } from "./use-save-shortcut.ts";
import { useStudio, useStudioEditorSession } from "../use-studio.ts";
type CatalogEntry = components["schemas"]["CatalogEntry"];
@@ -176,6 +177,12 @@ export function DocumentEditorScreen({ documentId }: { documentId: string }) {
}
}, [begin, editor, setStatus, studio]);
/*
`save` CLEAN·SAVING·CONFLICT
.
*/
useSaveShortcut(save);
/**
* .
*
@@ -1,11 +1,9 @@
import { useRef, useState, type KeyboardEvent } from "react";
import type { components } from "../../../contracts/studio/generated.ts";
import type { Asset } from "../../../contracts/studio/contract.ts";
import type { DocumentEditorController } from "./document-editor-controller.ts";
import { CASE_FIELD_PATHS, CaseFields } from "./case-fields.tsx";
import { COMMON_FIELD_PATHS, CommonDocumentFields } from "./common-document-fields.tsx";
import { DocumentStatusRail } from "./document-status-rail.tsx";
import { DocumentStatusBar } from "./document-status-bar.tsx";
import { InstantPreview } from "./instant-preview.tsx";
import { DECISION_FIELD_PATHS, ProjectDecisionFields } from "./project-decision-fields.tsx";
import { issuesOutside } from "./field-issues.tsx";
@@ -28,22 +26,6 @@ export function DocumentEditor({
onAssetsObserved: (assets: readonly Asset[]) => void;
onAssetUploaded: (asset: Asset) => void;
}) {
const [tab, setTab] = useState<"EDIT" | "PREVIEW">("EDIT");
const editTab = useRef<HTMLButtonElement>(null);
const previewTab = useRef<HTMLButtonElement>(null);
const selectTab = (next: "EDIT" | "PREVIEW") => {
setTab(next);
(next === "EDIT" ? editTab : previewTab).current?.focus();
};
const keyDown = (event: KeyboardEvent<HTMLButtonElement>) => {
let next: "EDIT" | "PREVIEW" | null = null;
if (event.key === "ArrowLeft" || event.key === "ArrowRight") next = tab === "EDIT" ? "PREVIEW" : "EDIT";
if (event.key === "Home") next = "EDIT";
if (event.key === "End") next = "PREVIEW";
if (!next) return;
event.preventDefault();
selectTab(next);
};
const topics = catalog.filter(({ type }) => type === "TOPIC");
const projects = catalog.filter(({ type }) => type === "PROJECT");
const relations = catalog.filter(({ type }) => type === "RELATION");
@@ -66,35 +48,47 @@ export function DocumentEditor({
: DECISION_FIELD_PATHS;
const unplaced = issuesOutside(issues, [...COMMON_FIELD_PATHS, ...kindPaths]);
/*
. ,
,
. .
. `aria-labelledby`
landmark "여기는 편집, 저기는 미리보기" .
*/
return (
<div className="studio-editor-page">
<div className="studio-editor-tabs" role="tablist" aria-label="문서 편집 화면">
<button id="studio-edit-tab" ref={editTab} type="button" role="tab" aria-selected={tab === "EDIT"} aria-controls="studio-edit-panel" tabIndex={tab === "EDIT" ? 0 : -1} onClick={() => selectTab("EDIT")} onKeyDown={keyDown}></button>
<button id="studio-preview-tab" ref={previewTab} type="button" role="tab" aria-selected={tab === "PREVIEW"} aria-controls="studio-preview-panel" tabIndex={tab === "PREVIEW" ? 0 : -1} onClick={() => selectTab("PREVIEW")} onKeyDown={keyDown}> </button>
</div>
<div className="studio-editor-layout">
<div className="studio-editor-workspace">
<div id="studio-edit-panel" role="tabpanel" aria-labelledby="studio-edit-tab" hidden={tab !== "EDIT"}>
<header className="studio-editor-heading">
<p className="studio-eyebrow">{controller.draft.kind} · VERSION {controller.saved.version}</p>
<h1> </h1>
<p>{controller.draft.title || "제목 없는 작업본"}</p>
</header>
<CommonDocumentFields draft={controller.draft} topics={topics} projects={projects} relations={relations} issues={issues} onUpdate={controller.update} />
{controller.draft.kind === "CASE"
? <CaseFields draft={controller.draft} issues={issues} onChange={controller.replace} onAssetsObserved={onAssetsObserved} onAssetUploaded={onAssetUploaded} />
: controller.draft.kind === "REFERENCE"
? <ReferenceFields draft={controller.draft} issues={issues} onChange={controller.replace} />
: controller.draft.kind === "QUESTION"
? <QuestionFields draft={controller.draft} evidence={evidence} issues={issues} onChange={controller.replace} />
: <ProjectDecisionFields draft={controller.draft} issues={issues} onChange={controller.replace} />}
</div>
<div id="studio-preview-panel" role="tabpanel" aria-labelledby="studio-preview-tab" hidden={tab !== "PREVIEW"}>
<div className="studio-editor-split">
<section className="studio-editor-workspace" aria-labelledby="studio-edit-title">
<header className="studio-editor-heading">
<p className="studio-eyebrow">{controller.draft.kind} · VERSION {controller.saved.version}</p>
<h1 id="studio-edit-title"> </h1>
<p>{controller.draft.title || "제목 없는 작업본"}</p>
</header>
<CommonDocumentFields draft={controller.draft} topics={topics} projects={projects} relations={relations} issues={issues} onUpdate={controller.update} />
{controller.draft.kind === "CASE"
? <CaseFields draft={controller.draft} issues={issues} onChange={controller.replace} onAssetsObserved={onAssetsObserved} onAssetUploaded={onAssetUploaded} />
: controller.draft.kind === "REFERENCE"
? <ReferenceFields draft={controller.draft} issues={issues} onChange={controller.replace} />
: controller.draft.kind === "QUESTION"
? <QuestionFields draft={controller.draft} evidence={evidence} issues={issues} onChange={controller.replace} />
: <ProjectDecisionFields draft={controller.draft} issues={issues} onChange={controller.replace} />}
</section>
{/*
, .
.
*/}
<section className="studio-editor-preview" aria-labelledby="studio-preview-title">
<header className="studio-editor-preview__heading">
<p className="studio-eyebrow">LIVE</p>
<h2 id="studio-preview-title"> </h2>
</header>
<div className="studio-editor-preview__body">
<InstantPreview draft={controller.draft} catalog={catalog} assets={assets} />
</div>
</div>
<DocumentStatusRail controller={controller} unplacedIssues={unplaced} />
</section>
</div>
<DocumentStatusBar controller={controller} unplacedIssues={unplaced} />
</div>
);
}
@@ -0,0 +1,91 @@
import type { DocumentEditorController } from "./document-editor-controller.ts";
import type { FieldIssue } from "./field-issues.tsx";
const labels = {
CLEAN: "저장됨",
DIRTY: "저장되지 않음",
SAVING: "저장 중…",
CONFLICT: "저장 충돌",
} as const;
const kindLabels = {
CASE: "CASE",
REFERENCE: "REFERENCE",
QUESTION: "QUESTION",
PROJECT_DECISION: "Decision",
} as const;
/**
* · .
*
* rail . ,
* rail .
* ·
* .
*
* `aside` .
* , .
*/
export function DocumentStatusBar({
controller,
unplacedIssues,
}: {
controller: DocumentEditorController;
/**
* . ,
* .
*/
unplacedIssues: readonly FieldIssue[];
}) {
const busy = controller.status === "SAVING" || controller.publishing;
return (
<aside className="studio-document-status-bar" aria-labelledby="studio-document-status-title">
<h2 id="studio-document-status-title" className="studio-visually-hidden"> </h2>
<p className={`studio-editor-status studio-editor-status--${controller.status.toLowerCase()}`} role="status" aria-label="편집 상태">{labels[controller.status]}</p>
<dl><div><dt> </dt><dd>{controller.saved.version}</dd></div><div><dt></dt><dd>{kindLabels[controller.draft.kind]}</dd></div></dl>
{/*
.
, .
.
*/}
{controller.status === "CONFLICT" ? (
<p className="studio-editor-conflict" role="alert"> . .</p>
) : controller.saveError ? (
<p className="studio-editor-conflict" role="alert">{controller.saveError}</p>
) : controller.publishError ? (
<p className="studio-editor-conflict" role="alert">{controller.publishError}</p>
) : (
<p className="studio-document-status-bar__note"> . Ctrl+S . .</p>
)}
<div className="studio-document-status-bar__actions">
<button type="button" title="Ctrl+S" onClick={() => { void controller.save(); }} disabled={busy || controller.status === "CLEAN" || controller.status === "CONFLICT"}>{controller.status === "SAVING" ? "저장 중…" : "저장"}</button>
{/*
.
id id,
(`PublishStudioDocumentUseCase`).
. ,
, . .
.
*/}
<button
className="studio-primary-button"
type="button"
onClick={() => { void controller.publish(); }}
disabled={busy || controller.status === "CONFLICT"}
>
{controller.publishing ? "게시 중…" : "게시"}
</button>
</div>
{unplacedIssues.length ? (
<ul className="studio-editor-unplaced-issues" aria-label="칸에 붙지 못한 지적">
{unplacedIssues.map((issue) => (
<li key={`${issue.code}:${issue.path}`} data-severity={issue.severity}>
{issue.message} <code>{issue.path}</code>
</li>
))}
</ul>
) : null}
</aside>
);
}
@@ -1,79 +0,0 @@
import type { DocumentEditorController } from "./document-editor-controller.ts";
import type { FieldIssue } from "./field-issues.tsx";
const labels = {
CLEAN: "저장됨",
DIRTY: "저장되지 않음",
SAVING: "저장 중…",
CONFLICT: "저장 충돌",
} as const;
const kindLabels = {
CASE: "CASE",
REFERENCE: "REFERENCE",
QUESTION: "QUESTION",
PROJECT_DECISION: "Decision",
} as const;
export function DocumentStatusRail({
controller,
unplacedIssues,
}: {
controller: DocumentEditorController;
/**
* . ,
* .
*/
unplacedIssues: readonly FieldIssue[];
}) {
const busy = controller.status === "SAVING" || controller.publishing;
return (
<aside className="studio-document-status-rail" aria-labelledby="studio-document-status-title">
<p className="studio-eyebrow">WORKING COPY</p>
<h2 id="studio-document-status-title"> </h2>
<p className={`studio-editor-status studio-editor-status--${controller.status.toLowerCase()}`} role="status" aria-label="편집 상태">{labels[controller.status]}</p>
<dl><div><dt> </dt><dd>{controller.saved.version}</dd></div><div><dt></dt><dd>{kindLabels[controller.draft.kind]}</dd></div></dl>
<button type="button" onClick={() => { void controller.save(); }} disabled={busy || controller.status === "CLEAN" || controller.status === "CONFLICT"}>{controller.status === "SAVING" ? "저장 중…" : "저장"}</button>
{/*
.
id id,
(`PublishStudioDocumentUseCase`).
. ,
, . .
.
*/}
<button
className="studio-primary-button"
type="button"
onClick={() => { void controller.publish(); }}
disabled={busy || controller.status === "CONFLICT"}
>
{controller.publishing ? "게시 중…" : "게시"}
</button>
{/*
.
, .
.
*/}
{controller.status === "CONFLICT" ? (
<p className="studio-editor-conflict" role="alert"> . .</p>
) : controller.saveError ? (
<p className="studio-editor-conflict" role="alert">{controller.saveError}</p>
) : controller.publishError ? (
<p className="studio-editor-conflict" role="alert">{controller.publishError}</p>
) : (
<p> . .</p>
)}
{unplacedIssues.length ? (
<ul className="studio-editor-unplaced-issues" aria-label="칸에 붙지 못한 지적">
{unplacedIssues.map((issue) => (
<li key={`${issue.code}:${issue.path}`} data-severity={issue.severity}>
{issue.message} <code>{issue.path}</code>
</li>
))}
</ul>
) : null}
</aside>
);
}
@@ -41,12 +41,19 @@ export function HomeFocusEditor() {
useEffect(() => {
let cancelled = false;
void Promise.all([
managementGateway.getHomeFocus(),
managementGateway.listProjects(0, 50),
gateway.getCatalog({ type: "RELATION", limit: 100 }),
]).then(
([current, projectPage, catalog]) => {
/*
try/catch . `Promise.all([gateway.foo()])` `foo`
(: )
rejection handler .
. .
*/
void (async () => {
try {
const [current, projectPage, catalog] = await Promise.all([
managementGateway.getHomeFocus(),
managementGateway.listProjects(0, 50),
gateway.getCatalog({ type: "RELATION", limit: 100 }),
]);
if (cancelled) return;
setFocus(current);
setProjects(projectPage.items ?? []);
@@ -55,11 +62,10 @@ export function HomeFocusEditor() {
setQuestionId(current.openQuestionId ?? "");
setDecisionId(current.recentDecisionId ?? "");
setError("");
},
() => {
} catch {
if (!cancelled) setError("홈 설정을 불러오지 못했습니다.");
},
);
}
})();
return () => {
cancelled = true;
};
@@ -10,6 +10,7 @@ import { managementFailureMessage } from "../../../application/ports/management-
import { useRouteInput } from "../../../../../presentation/routes/route-input.tsx";
import { GuardedStudioLink } from "./guarded-studio-link.tsx";
import { slugFromName } from "./slug-from-name.ts";
import { useSaveShortcut } from "./use-save-shortcut.ts";
import { useStudio } from "../use-studio.ts";
type CatalogEntry = components["schemas"]["CatalogEntry"];
@@ -114,14 +115,6 @@ export function ProjectEditor() {
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);
@@ -130,13 +123,15 @@ export function ProjectEditor() {
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]) => {
// 동기적으로 던지는 호출도 잡는다 — `home-focus-editor` 와 같은 이유다.
void (async () => {
try {
const [loaded, activityList, topicList, catalog] = await Promise.all([
managementGateway.getProject(projectId),
managementGateway.listProjectActivities(projectId),
managementGateway.listTopics(),
gateway.getCatalog({ type: "RELATION", limit: 100 }),
]);
if (cancelled) return;
setProject(loaded);
setDraft(toDraft(loaded));
@@ -144,11 +139,10 @@ export function ProjectEditor() {
setTopics(topicList.filter((topic) => topic.status !== "ARCHIVED"));
setRecords(catalog.items ?? []);
setError("");
},
() => {
} catch {
if (!cancelled) setError("프로젝트를 불러오지 못했습니다.");
},
);
}
})();
return () => {
cancelled = true;
};
@@ -218,87 +212,11 @@ export function ProjectEditor() {
}
};
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);
}
};
// 폼 제출과 같은 일을 하므로 submit 이벤트를 만들어 보낸다 — 검증·기본값 처리가 한 곳에 남는다.
useSaveShortcut(
() => void save({ preventDefault: () => {} } as FormEvent),
Boolean(project && draft) && !pending,
);
const published = project ? project.targetVisibility !== "PRIVATE" : false;
@@ -445,9 +363,19 @@ export function ProjectEditor() {
>
<div className="studio-editor-section-heading">
<p className="studio-eyebrow">NARRATIVE</p>
<h2 id="studio-project-narrative-title"></h2>
<p> . .</p>
<h2 id="studio-project-narrative-title"></h2>
<p>
&ldquo; &rdquo; .
</p>
</div>
{/*
.
.
, .
( , ).
.
*/}
<div className="studio-field-grid">
<label className="studio-field studio-field--wide">
<span></span>
@@ -456,30 +384,13 @@ export function ProjectEditor() {
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 })
}
/>
<small> .</small>
</label>
</div>
</section>
<div className="studio-editor-footer">
<button className="studio-primary-button" type="submit" disabled={pending}>
<button className="studio-primary-button" type="submit" title="Ctrl+S" disabled={pending}>
{pending ? "저장하는 중" : "저장"}
</button>
<p className="studio-field-note">
@@ -490,117 +401,22 @@ export function ProjectEditor() {
</div>
</form>
<section className="studio-editor-section" aria-labelledby="studio-project-activity-title">
<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>
<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>
<p className="studio-empty-inline">
.
</p>
) : (
<div className="studio-document-list">
{activities.map((activity) => (
@@ -609,61 +425,27 @@ export function ProjectEditor() {
<div className="studio-document-title">
<h2>{activity.title}</h2>
<p>
{activity.summary || "요약 없음"}
{activity.relatedResourceId ? " · 기록 연결됨" : " · 연결된 기록 없음"}
{activity.visibility === "PUBLIC"
? "공개 타임라인에 나옵니다"
: "비공개 — 타임라인에 나오지 않습니다"}
</p>
</div>
<dl>
<div>
<dt></dt>
<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>
<time dateTime={activity.occurredAt}>
{new Date(activity.occurredAt).toLocaleDateString("ko-KR", {
timeZone: "Asia/Seoul",
})}
</time>
</dd>
</div>
<div>
<dt></dt>
<dd>{activity.origin === "MANUAL" ? "손으로 적음" : "자동 기록"}</dd>
<dd>{activity.origin === "AUTO" ? "게시가 남김" : "손으로 적음"}</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>
@@ -7,6 +7,7 @@ import type {
import { managementFailureMessage } from "../../../application/ports/management-gateway-error.ts";
import { useRouteInput } from "../../../../../presentation/routes/route-input.tsx";
import { GuardedStudioLink } from "./guarded-studio-link.tsx";
import { useSaveShortcut } from "./use-save-shortcut.ts";
import { useStudio } from "../use-studio.ts";
/**
@@ -192,6 +193,8 @@ export function ReleaseEditor() {
}
};
useSaveShortcut(save, draft !== null && !pending);
const toggleChangeType = (value: string) => {
if (draft === null) return;
update({
@@ -309,6 +312,7 @@ export function ReleaseEditor() {
<button
className="studio-primary-button"
type="button"
title="Ctrl+S"
disabled={pending}
onClick={() => void save()}
>
@@ -0,0 +1,29 @@
import { useEffect } from "react";
/**
* Ctrl+S( Cmd+S) .
*
* <p> "페이지를 파일로 저장".
* .
* .
*
* <p>`enabled` .
* "지금 저장할 수 있는가" .
*/
export function useSaveShortcut(
save: () => void | Promise<void>,
enabled = true,
) {
useEffect(() => {
function onKeyDown(event: KeyboardEvent) {
if (event.key !== "s" && event.key !== "S") return;
if (!(event.ctrlKey || event.metaKey)) return;
// Ctrl+Shift+S 는 다른 뜻으로 쓰는 곳이 있어 가로채지 않는다.
if (event.shiftKey || event.altKey) return;
event.preventDefault();
if (enabled) void save();
}
window.addEventListener("keydown", onKeyDown);
return () => window.removeEventListener("keydown", onKeyDown);
}, [enabled, save]);
}
@@ -1,15 +1,37 @@
.studio-app .studio-editor-page,
.studio-app .studio-editor-layout,
.studio-app .studio-editor-split,
.studio-app .studio-editor-workspace,
.studio-app .studio-editor-workspace > [role="tabpanel"] { min-width: 0; }
.studio-app .studio-editor-preview { min-width: 0; }
.studio-app .studio-editor-loading { min-height: 240px; padding-block: 56px; color: var(--muted); }
.studio-app .studio-editor-tabs { display: flex; gap: 28px; border-bottom: 1px solid var(--line-strong); }
.studio-app .studio-editor-tabs button { position: relative; min-width: 112px; min-height: 44px; padding: 0; border: 0; background: transparent; color: var(--muted); font: inherit; font-weight: 650; }
.studio-app .studio-editor-tabs button[aria-selected="true"] { color: var(--ink); }
.studio-app .studio-editor-tabs button[aria-selected="true"]::after { position: absolute; right: 0; bottom: -1px; left: 0; height: 2px; background: var(--signal); content: ""; }
/*
편집과 미리보기를 화면에 나란히 둔다. 탭이었을 때는 번에 하나만 보였고, 고친 결과를
보려면 편집하던 자리를 화면에서 치워야 했다.
*/
/*
화면만 공용 컨테이너보다 넓다. `.studio-main` 모든 Studio 화면이 1180px 함께
쓰는데, 여기서는 폭에 본문 벌이 들어가야 해서 칸이 566px 좁아졌다. 헤더와
다른 화면은 그대로 두고 편집기가 놓인 경우에만 넓힌다.
*/
.studio-app:has(.studio-editor-split) .studio-header-inner,
.studio-app:has(.studio-editor-split) .studio-main { width: min(1600px, calc(100% - 80px)); }
.studio-app .studio-editor-layout { display: grid; grid-template-columns: minmax(0, 1fr) minmax(240px, 280px); gap: 64px; align-items: start; padding-top: 40px; }
/*
편집과 미리보기를 화면에 나란히 둔다. 탭이었을 때는 번에 하나만 보였고, 고친 결과를
보려면 편집하던 자리를 화면에서 치워야 했다.
모두 페이지 스크롤을 그대로 탄다. 미리보기를 붙박이로 두고 자체 스크롤을 주었더니
편집기를 내려도 미리보기는 제자리였고, 보려면 안을 따로 굴려야 했다 나란히 이유가
둘을 같이 보는 것인데 움직임이 갈라지면 이점이 없다.
*/
.studio-app .studio-editor-split { display: grid; grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); gap: 48px; padding-top: 40px; }
/* 반쪽 폭에는 62px 짜리 제목이 앉을 자리가 없다. */
.studio-app .studio-editor-split .studio-editor-heading h1 { font-size: clamp(28px, 3vw, 40px); }
.studio-app .studio-editor-preview { display: flex; flex-direction: column; padding-left: 40px; border-left: 1px solid var(--line); }
.studio-app .studio-editor-preview__heading { padding-bottom: 20px; border-bottom: 1px solid var(--line); }
.studio-app .studio-editor-preview__heading .studio-eyebrow { margin-bottom: 8px; }
.studio-app .studio-editor-preview__body { padding-top: 20px; }
.studio-app .studio-editor-heading { padding-bottom: 36px; border-bottom: 1px solid var(--line-strong); }
.studio-app .studio-editor-heading h1 { margin: 0; font-size: clamp(38px, 5vw, 62px); line-height: 1.05; letter-spacing: -0.045em; }
.studio-app .studio-editor-heading > p:last-child { max-width: 780px; margin: 18px 0 0; color: var(--muted); font-size: 17px; line-height: 1.65; overflow-wrap: anywhere; }
@@ -17,7 +39,7 @@
.studio-app .studio-editor-section-heading { margin-bottom: 24px; }
.studio-app .studio-editor-section-heading .studio-eyebrow { margin-bottom: 8px; }
.studio-app .studio-editor-section-heading h2,
.studio-app .studio-document-status-rail h2,
.studio-app .studio-editor-preview__heading h2,
.studio-app .studio-preview-error h2 { margin: 0; font-size: 25px; letter-spacing: -0.03em; }
.studio-app .studio-field-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 22px; }
@@ -71,27 +93,38 @@
.studio-app .studio-item-actions { display: flex; flex-wrap: wrap; gap: 8px; }
.studio-app .studio-item-actions button,
.studio-app .studio-add-item,
.studio-app .studio-document-status-rail button { min-height: 44px; padding-inline: 13px; border: 1px solid var(--line-strong); border-radius: 5px; background: var(--paper); color: var(--ink); font-weight: 650; }
.studio-app .studio-document-status-bar button { min-height: 44px; padding-inline: 13px; border: 1px solid var(--line-strong); border-radius: 5px; background: var(--paper); color: var(--ink); font-weight: 650; }
.studio-app .studio-item-actions button:disabled,
.studio-app .studio-add-item:disabled { opacity: 0.45; }
.studio-app .studio-add-item { margin-top: 12px; }
.studio-app .studio-resolution-fields { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 22px; }
.studio-app .studio-resolution-fields legend { grid-column: 1 / -1; }
.studio-app .studio-document-status-rail { position: sticky; top: 28px; min-width: 0; padding: 24px 0; border-top: 1px solid var(--line-strong); border-bottom: 1px solid var(--line-strong); }
.studio-app .studio-document-status-rail .studio-eyebrow { margin-bottom: 8px; }
.studio-app .studio-editor-status { margin: 20px 0; font-weight: 700; }
/*
작업 상태와 저장·게시를 화면 아래에 붙여 둔다. 문서가 길어져도 스크롤 위치와 무관하게 손이
닿는다. 배경을 불투명하게 두는 것은 장식이 아니라 필요다 밑으로 지나가는 본문이 비치면
상태 글자를 읽을 없다.
*/
.studio-app .studio-document-status-bar { position: sticky; bottom: 0; z-index: 2; display: flex; flex-wrap: wrap; align-items: center; gap: 12px 28px; min-width: 0; margin-top: 48px; padding: 16px 0; border-top: 1px solid var(--line-strong); background: var(--paper); }
.studio-app .studio-editor-status { margin: 0; font-weight: 700; }
.studio-app .studio-editor-status--dirty,
.studio-app .studio-editor-status--conflict,
.studio-app .studio-editor-conflict { color: #8f2f27; }
.studio-app .studio-editor-status--clean { color: #166748; }
.studio-app .studio-document-status-rail dl { display: grid; gap: 12px; margin: 0 0 20px; }
.studio-app .studio-document-status-rail dl div { display: flex; justify-content: space-between; gap: 16px; }
.studio-app .studio-document-status-rail dt { color: var(--muted); font-size: 12px; }
.studio-app .studio-document-status-rail dd { margin: 0; font-size: 13px; }
.studio-app .studio-document-status-rail button { width: 100%; border-color: var(--signal); background: var(--signal); color: #fff; }
.studio-app .studio-document-status-rail button:disabled { border-color: var(--line-strong); background: var(--paper); color: var(--muted); }
.studio-app .studio-document-status-rail > p:last-child { margin: 16px 0 0; color: var(--muted); font-size: 12px; line-height: 1.65; }
.studio-app .studio-document-status-bar dl { display: flex; gap: 20px; margin: 0; }
.studio-app .studio-document-status-bar dl div { display: flex; align-items: baseline; gap: 8px; }
.studio-app .studio-document-status-bar dt { color: var(--muted); font-size: 12px; }
.studio-app .studio-document-status-bar dd { margin: 0; font-size: 13px; }
.studio-app .studio-document-status-bar__note { flex: 1 1 240px; margin: 0; color: var(--muted); font-size: 12px; line-height: 1.6; }
/* 색은 위 묶음이 정한다 — 여기서 다시 칠하면 실패가 안내문처럼 보인다. */
.studio-app .studio-document-status-bar .studio-editor-conflict { flex: 1 1 240px; margin: 0; font-size: 12px; line-height: 1.6; }
.studio-app .studio-document-status-bar__actions { display: flex; align-items: center; gap: 12px; margin-left: auto; }
/* `.studio-primary-button` 세로로 쌓이는 자리에 맞춘 바깥 여백을 갖고 있다. 줄에
나란히 두면 여백이 높이를 늘리고, 버튼만 늘어나 짝이 어긋난다. */
.studio-app .studio-document-status-bar__actions .studio-primary-button { margin: 0; }
.studio-app .studio-document-status-bar button { min-width: 104px; border-color: var(--signal); background: var(--signal); color: #fff; }
.studio-app .studio-document-status-bar button:disabled { border-color: var(--line-strong); background: var(--paper); color: var(--muted); }
.studio-app .studio-document-status-bar .studio-editor-unplaced-issues { flex: 1 1 100%; }
.studio-app .studio-asset-panel { margin-top: 28px; padding-top: 28px; border-top: 1px solid var(--line); }
.studio-app .studio-asset-panel .studio-eyebrow { margin-bottom: 8px; }
@@ -107,7 +140,7 @@
matches this form's submit button, and both selectors carry the same
specificity, so only source order tells them apart. */
/* `minmax(0, 1fr)` and the row's `min-width: 0` are both load bearing, for the
reason `.studio-editor-layout` already needs the same pair: a grid track and
reason `.studio-editor-split` already needs the same pair: a grid track and
a flex container both default to an automatic minimum of their min-content
size, so at 360px the search row sized itself to 366px inside a 328px column
and pushed the submit button off-screen (document scrollWidth 382). */
@@ -128,15 +161,19 @@
.studio-app .studio-preview-error ul { margin: 20px 0 0; padding-left: 20px; color: #8f2f27; line-height: 1.7; overflow-wrap: anywhere; }
@media (max-width: 1024px) {
.studio-app .studio-editor-layout { grid-template-columns: minmax(0, 1fr); gap: 40px; }
.studio-app .studio-document-status-rail { position: static; }
.studio-app .studio-editor-split { grid-template-columns: minmax(0, 1fr); gap: 40px; }
.studio-app .studio-editor-split .studio-editor-heading h1 { font-size: clamp(38px, 5vw, 62px); }
.studio-app .studio-editor-preview { padding-top: 8px; padding-left: 0; border-top: 1px solid var(--line-strong); border-left: 0; }
}
@media (max-width: 767px) {
.studio-app .studio-editor-heading { padding-bottom: 28px; }
.studio-app .studio-editor-tabs { gap: 18px; }
.studio-app .studio-editor-tabs button { flex: 1; min-width: 0; }
.studio-app .studio-editor-layout { padding-top: 28px; }
.studio-app .studio-editor-split { padding-top: 28px; }
/* 안내문은 줄로 접혀 뷰포트의 20% 고정으로 가져간다. 실패 문구는 남기고 안내만 접는다
Ctrl+S 여기서 누를 있는 것도 아니다. */
.studio-app .studio-document-status-bar__note { display: none; }
.studio-app .studio-document-status-bar__actions { flex: 1 1 100%; margin-left: 0; }
.studio-app .studio-document-status-bar__actions button { flex: 1; min-width: 0; }
.studio-app .studio-field-grid,
.studio-app .studio-resolution-fields { grid-template-columns: minmax(0, 1fr); }
.studio-app .studio-field--wide,
@@ -181,6 +218,6 @@
저장과 게시는 되돌릴 있는 정도가 다르다 하나는 초안을 남기고, 하나는 공개한다. 둘이
맞붙어 있으면 누르려던 것을 지나쳐 누르기 쉬우므로 사이를 벌린다.
*/
.studio-app .studio-document-status-rail button + button { margin-top: 10px; }
.studio-app .studio-document-status-bar__actions { gap: 12px; }
/* 게시만 강조한다. 저장은 되돌릴 수 있으므로 같은 무게로 부를 이유가 없다. */
.studio-app .studio-document-status-rail button:not(.studio-primary-button) { border-color: var(--line-strong); background: var(--paper); color: var(--ink); }
.studio-app .studio-document-status-bar button:not(.studio-primary-button) { border-color: var(--line-strong); background: var(--paper); color: var(--ink); }
@@ -461,8 +461,7 @@ test("inserts the directive at the saved cursor position and the live preview re
`${body.slice(0, cursor)}\n\n${expectedDirective}${body.slice(cursor)}`,
);
await user.click(screen.getByRole("tab", { name: "즉시 미리보기" }));
const panel = screen.getByRole("tabpanel", { name: "즉시 미리보기" });
const panel = screen.getByRole("region", { name: "즉시 미리보기" });
expect(within(panel).queryByRole("alert")).not.toBeInTheDocument();
// `zoom: true` (DIAGRAM kind) renders the figure's image twice -- once as
@@ -1819,12 +1818,12 @@ test("a Picker search never drops an already-inserted asset out of Instant Previ
// must narrow; the preview must not.
await user.type(screen.getByLabelText("Asset 검색"), "other");
await user.click(screen.getByRole("button", { name: "검색" }));
const picker = screen.getByRole("group", { name: "본문에 Asset 삽입" });
await waitFor(() =>
expect(screen.queryByRole("button", { name: /inserted-diagram/ })).not.toBeInTheDocument(),
expect(within(picker).queryByRole("button", { name: /inserted-diagram/ })).not.toBeInTheDocument(),
);
await user.click(screen.getByRole("tab", { name: "즉시 미리보기" }));
const panel = screen.getByRole("tabpanel", { name: "즉시 미리보기" });
const panel = screen.getByRole("region", { name: "즉시 미리보기" });
assert.equal(
within(panel).queryByRole("alert")?.textContent ?? null,
@@ -296,7 +296,7 @@ describe("TechLog explore discovery", () => {
const user = userEvent.setup();
const { router } = await renderDiscoveryRoute(
"TECH_LOG_EXPLORE",
"/explore?type=CASE&topic=JPA&project=backend-skeleton",
"/explore?type=CASE&topic=jpa&project=backend-skeleton",
);
expect(screen.getByRole("heading", { level: 1, name: "탐색" })).toBeVisible();
@@ -306,22 +306,28 @@ describe("TechLog explore discovery", () => {
// 필터의 선택지는 카탈로그가 도착한 뒤 채워지고, select 의 값도 그때 설정된다.
await waitFor(() => {
expect(screen.getByLabelText("유형")).toHaveValue("CASE");
expect(screen.getByLabelText("주제")).toHaveValue("JPA");
expect(screen.getByLabelText("주제")).toHaveValue("jpa");
expect(screen.getByLabelText("프로젝트")).toHaveValue("backend-skeleton");
});
// 주제 선택지는 보이는 이름과 보내는 값이 다르다. 값이 이름이면 slug 로 거르는 API 가
// 0건을 돌려주고, 화면은 「조건에 맞는 공개 기록이 없습니다」만 남는다 — 운영에서 실제로
// 그랬다. 목록에서 유도한 값이라 이 단언이 없으면 조용히 되돌아간다.
expect(
within(screen.getByLabelText("주제")).getByRole("option", { name: "Authentication" }),
).toHaveValue("authentication");
expect(screen.getByText("1개의 공개 기록")).toBeVisible();
expect(
screen.getByRole("link", { name: /컬렉션 Fetch Join과 페이징은 왜 충돌하는가/ }),
).toHaveAttribute("href", "/cases/collection-fetch-join-pagination");
await user.selectOptions(screen.getByLabelText("유형"), "QUESTION");
await user.selectOptions(screen.getByLabelText("주제"), "Authentication");
await user.selectOptions(screen.getByLabelText("주제"), "authentication");
await user.selectOptions(screen.getByLabelText("프로젝트"), "auth-lab");
await user.click(screen.getByRole("button", { name: "적용" }));
await waitFor(() => {
expect(router.state.location.search).toBe(
"?project=auth-lab&topic=Authentication&type=QUESTION",
"?project=auth-lab&topic=authentication&type=QUESTION",
);
});
expect(screen.getByText("1개의 공개 기록")).toBeVisible();
@@ -42,6 +42,13 @@ const routeComponents = {
type DocumentRouteId = keyof typeof routeComponents;
/** 머리말이 거는 주제 링크를 확인하기 위한 픽스처의 이름 → slug 대응. */
const topicSlugs: Readonly<Record<string, string>> = {
JPA: "jpa",
Authentication: "authentication",
Redis: "redis",
};
class NoopIntersectionObserver implements IntersectionObserver {
readonly root = null;
readonly rootMargin = "0px";
@@ -205,13 +212,30 @@ describe("TechLog canonical Public documents", () => {
const main = screen.getByRole("main");
expect(within(main).getByRole("heading", { level: 1, name: title })).toBeVisible();
expect(within(main).getByText(evidence, { exact: false })).toBeVisible();
expect(within(main).getByRole("navigation", { name: "문서 경로" })).toHaveTextContent(
`${kind}/${topic}/${project}`,
/*
.
"그 글이 문서에 있는가" .
*/
const [firstEvidence] = within(main).getAllByText(evidence, { exact: false });
expect(firstEvidence).toBeVisible();
const breadcrumb = within(main).getByRole("navigation", { name: "문서 경로" });
expect(breadcrumb).toHaveTextContent(`${kind}/${topic}/${project}`);
/*
.
`/topics/:slug` ,
404 .
*/
expect(within(breadcrumb).getByRole("link", { name: topic })).toHaveAttribute(
"href",
`/explore?topic=${topicSlugs[topic]}`,
);
if (path === "/cases/collection-fetch-join-pagination") {
expect(within(main).getByText(`게시 ${published} · 마지막 검증 2026.08.11`)).toBeVisible();
/*
Case . `.case-meta` Case
`.public-document-header dl` . .
*/
if (kind === "Case") {
expect(container.querySelector(".case-meta")).toHaveTextContent(`게시 ${published}`);
} else {
const metadata = container.querySelector(".public-document-header dl");
expect(metadata).toHaveTextContent(`유형${kind}`);
@@ -258,12 +282,12 @@ describe("TechLog canonical Public documents", () => {
expect(image).toHaveAttribute("loading", "lazy");
});
it("uses the generic Case markup and omits source relations for the canonical empty state", async () => {
it("uses the one Case layout and omits source relations for the canonical empty state", async () => {
const generic = await renderDocumentRoute(
"TECH_LOG_CASE",
"/cases/redis-adapter-ttl-boundary",
);
expect(screen.getByRole("main")).toHaveClass("shell", "public-document-page");
expect(screen.getByRole("main")).toHaveClass("case-page");
expect(generic.container.querySelector("#ownership")).toHaveTextContent(
"정책과 저장 명령의 주인을 구분하기",
);
@@ -252,35 +252,26 @@ describe("TechLog project screens", () => {
]);
});
it("keeps project activity ordered and preserves self-fragment and record links", async () => {
/*
.
, "기록" "활동" .
.
*/
it("keeps project activity ordered and leaves reading to the records screen", async () => {
const { container } = await renderPublicRoute(
"TECH_LOG_PROJECT_ACTIVITY",
"/projects/backend-skeleton/activity",
);
expect(
Array.from(container.querySelectorAll(".project-activity-list > li > article"), (item) => ({
id: item.id,
href: item.querySelector("a")?.getAttribute("href"),
label: item.querySelector("a")?.textContent,
})),
).toEqual([
{
id: "fetch-join-case-published",
href: "/cases/collection-fetch-join-pagination",
label: "연결된 공개 기록 읽기",
},
{
id: "storage-contract",
href: "/projects/backend-skeleton/activity#storage-contract",
label: "이 활동 위치 열기",
},
{
id: "redis-case-published",
href: "/cases/redis-adapter-ttl-boundary",
label: "연결된 공개 기록 읽기",
},
const items = Array.from(
container.querySelectorAll(".project-activity-list > li > article"),
);
expect(items.map((item) => item.id)).toEqual([
"fetch-join-case-published",
"storage-contract",
"redis-case-published",
]);
expect(items.flatMap((item) => Array.from(item.querySelectorAll("a")))).toEqual([]);
});
});
@@ -0,0 +1,131 @@
import { strict as assert } from "node:assert";
import { test } from "vitest";
import type { components } from "../../../src/features/tech-log/contracts/public/generated.ts";
import { createHttpPublicContentGateway } from "../../../src/features/tech-log/adapters/http/http-public-content-gateway.ts";
type QuestionDetail = components["schemas"]["QuestionDetailResponse"];
/*
Open Question .
`points` `{group, items}` `.filter` ,
`QuestionPointGroup` ****. `.filter`
. `as` .
"게시했는데 public 에 안 뜬다" , .
,
. .
*/
function gatewayReturning(value: unknown) {
return createHttpPublicContentGateway({
operations: {
execute: () =>
Promise.resolve({
kind: "SUCCESS" as const,
value,
metadata: { status: 200 },
effect: "NOT_APPLICABLE" as const,
}),
},
});
}
const DETAIL = {
canonicalPath: "/questions/refresh-rotation-replica-contention",
indexable: true,
question: {
question: "Refresh Token Rotation과 다중 Replica 경쟁을 어떻게 처리할 것인가",
summary: "두 replica가 같은 refresh token으로 동시에 갱신할 수 있다.",
context: "",
importance: "",
status: "OPEN",
nextVerification: "저장소를 공유한 뒤에 재현한다.",
points: {
facts: ["realm은 refresh token rotation과 재사용 허용 0회를 쓴다."],
assumptions: ["운영에서는 replica가 둘 이상이고 저장소를 공유한다."],
unknowns: ["같은 refresh token으로 동시에 갱신하면 어떻게 되는지."],
constraints: ["이미 발급된 access token은 만료 전까지 계속 통한다."],
},
updates: [],
openedAt: "2026-08-24T00:00:00.000Z",
updatedAt: "2026-08-26T13:41:53.974512Z",
},
relations: {
primaryProject: {
type: "PROJECT",
title: "KeyCloak Patterns",
path: "/projects/keycloak-patterns",
summary: "Keycloak을 쓰면서 실제로 부딪힌 인증 경계를 기록합니다",
},
resultCase: {
type: "CASE",
title: "Refresh Token 관리만 서버로 이전, Access Token은 여전히 Browser에 노출",
path: "/cases/split-custody-access-token",
summary: "토큰 교환과 토큰 관리의 책임이 서버로 이전했다.",
},
derivedReferences: [],
},
} satisfies QuestionDetail;
test("a published Open Question maps its four point groups into the screen's fields", async () => {
const gateway = gatewayReturning(DETAIL);
const record = await gateway.getRecord("QUESTION", "refresh-rotation-replica-contention");
assert.ok(record, "게시된 질문은 상세로 돌아와야 한다");
assert.equal(record.kind, "QUESTION");
assert.equal(record.title, DETAIL.question.question);
assert.equal(record.questionStatus, "OPEN");
assert.deepEqual([...record.facts], DETAIL.question.points.facts);
assert.deepEqual([...record.assumptions], DETAIL.question.points.assumptions);
assert.deepEqual([...record.unknowns], DETAIL.question.points.unknowns);
assert.deepEqual([...record.constraints], DETAIL.question.points.constraints);
assert.equal(record.nextValidation, DETAIL.question.nextVerification);
// 질문 상세는 프로젝트를 `relations` 에 담는다 — `question` 에서 찾으면 머리말의 프로젝트
// 칸이 늘 비어 있다.
assert.equal(record.projectTitle, "KeyCloak Patterns");
assert.equal(record.projectSlug, "keycloak-patterns");
});
/*
. `primaryProject` .
*/
test("a question's relations read their reason from the contract's own group names", async () => {
const gateway = gatewayReturning(DETAIL);
const record = await gateway.getRecord("QUESTION", "refresh-rotation-replica-contention");
assert.deepEqual(
[...record!.relations].map((relation) => ({ reason: relation.reason, path: relation.path })),
[
{
reason: "이 질문에서 나온 기록",
path: "/cases/split-custody-access-token",
},
],
);
});
/*
. `satisfies` ,
.
*/
test("the contract names the four point groups the screen renders", () => {
const points: components["schemas"]["QuestionPointGroup"] = {
facts: [],
assumptions: [],
unknowns: [],
constraints: [],
};
assert.deepEqual(Object.keys(points).sort(), [
"assumptions",
"constraints",
"facts",
"unknowns",
]);
});
@@ -0,0 +1,71 @@
import { strict as assert } from "node:assert";
import { test } from "vitest";
import type { components } from "../../../src/features/tech-log/contracts/public/generated.ts";
type ReferenceBody =
components["schemas"]["ReferenceDetailResponse"]["reference"];
/*
.
Reference . Studio "
" .
`purposeSummary`, `applyWhenMarkdown`, `exceptionsMarkdown`, `examplesMarkdown`. undefined
`as string` .
****. ,
.
*/
test("the public Reference contract keeps the fields the screen reads", () => {
const reference = {
title: "",
// 제목 바로 아래에 오는 것은 문서의 요약이다. scopeSummary 는 그 아래 "이 기준을 쓰는 이유"다.
summary: "",
scopeSummary: "",
appliesTo: [],
excludedScope: [],
rules: [{ title: "", body: "" }],
examples: [],
freshnessStatus: "CURRENT",
content: "",
contentFormat: "MARKDOWN",
contentFormatVersion: 1,
tags: [],
publishedAt: "",
updatedAt: "",
} satisfies ReferenceBody;
// 화면이 읽는 이름 그대로. 하나라도 계약에서 사라지면 위의 `satisfies` 가 먼저 깨진다.
assert.deepEqual(Object.keys(reference).sort(), [
"appliesTo",
"content",
"contentFormat",
"contentFormatVersion",
"examples",
"excludedScope",
"freshnessStatus",
"publishedAt",
"rules",
"scopeSummary",
"summary",
"tags",
"title",
"updatedAt",
]);
});
/*
Reference `content` . Studio
`body_markdown` `content`
.
*/
test("a rule carries its own title and body, not a slice of markdown", () => {
const rule: NonNullable<ReferenceBody["rules"]>[number] = {
title: "Authorization Endpoint에는 client_secret을 보내지 않는다",
body: "이 요청은 브라우저의 full-page navigation으로 나간다.",
};
assert.equal(typeof rule.title, "string");
assert.equal(typeof rule.body, "string");
});
+11 -3
View File
@@ -386,7 +386,12 @@ describe("shared Public record renderer", () => {
);
});
it("uses generic Case markup and does not present generatedAt as publication", () => {
/*
Case "축약본" .
, breadcrumb Case
. .
*/
it("uses the one Case layout and does not present generatedAt as publication", () => {
renderInRouter(
<PublicRecordRenderer
{...renderDependencies}
@@ -402,14 +407,17 @@ describe("shared Public record renderer", () => {
);
const main = screen.getByRole("main");
expect(main).toHaveClass("shell", "public-document-page");
expect(main).toHaveClass("case-page");
expect(main).toHaveAttribute("id", "main-content");
expect(screen.getByText("게시 전")).toBeVisible();
// 완성된 배치에서는 "게시 전" 이 기록 줄의 한 문장 안에 들어간다 — 홀로 선 노드가 아니다.
expect(main).toHaveTextContent("게시 전");
expect(main).not.toHaveTextContent("2035.05.06");
expect(screen.getByRole("region", { name: "문제와 결론" })).toHaveTextContent(
"문제문제결론결론",
);
expect(screen.getByText("일반 본문")).toBeVisible();
// 제목이 없는 본문에는 목차를 그리지 않는다 — 빈 레일만 남는다.
expect(screen.queryByLabelText("문서 목차")).toBeNull();
});
it("renders supplied Reference and Question preview fields and empty copy", () => {
@@ -39,7 +39,9 @@ const expectedRoutes = [
["TECH_LOG_STUDIO_PUBLICATION_PREVIEW", "/studio/publications/:publicationEventId/preview", "STUDIO", "TechLogPublicationEventIdParams", null],
["TECH_LOG_STUDIO_ASSETS", "/studio/assets", "STUDIO", null, null],
["TECH_LOG_STUDIO_TAXONOMY", "/studio/taxonomy", "STUDIO", null, null],
["TECH_LOG_STUDIO_PROJECT_EDIT", "/studio/projects/:id", "STUDIO", "TechLogDocumentIdParams", null],
["TECH_LOG_STUDIO_RELEASES", "/studio/releases", "STUDIO", null, null],
["TECH_LOG_STUDIO_RELEASE_EDIT", "/studio/releases/:id", "STUDIO", "TechLogDocumentIdParams", null],
["TECH_LOG_STUDIO_NOT_FOUND", "/studio/*", "STUDIO", "TechLogStudioSplat", null],
["NOT_FOUND", "*", "PUBLIC", "NotFoundSplat", null],
] as const;
@@ -72,13 +74,15 @@ const expectedTitles = {
TECH_LOG_STUDIO_PUBLICATION_PREVIEW: "게시 Snapshot",
TECH_LOG_STUDIO_ASSETS: "Asset",
TECH_LOG_STUDIO_TAXONOMY: "주제와 프로젝트",
TECH_LOG_STUDIO_PROJECT_EDIT: "프로젝트 편집",
TECH_LOG_STUDIO_RELEASES: "릴리즈",
TECH_LOG_STUDIO_RELEASE_EDIT: "릴리즈 편집",
TECH_LOG_STUDIO_NOT_FOUND: "Studio 화면을 찾을 수 없습니다",
NOT_FOUND: "페이지를 찾을 수 없습니다.",
} as const;
describe("TechLog route boundary contract", () => {
it("freezes the standalone 29-route inventory before runtime installation", () => {
it("freezes the standalone 31-route inventory before runtime installation", () => {
expect(
Object.values(TECH_LOG_ROUTE_REGISTRY).map((definition) => [
definition.routeId,
@@ -153,7 +157,7 @@ describe("TechLog route boundary contract", () => {
for (const locale of ["ko-KR", "en-US"] as const) {
const catalog: Readonly<Record<string, string>> =
TECH_LOG_MESSAGE_CATALOGS[locale];
expect(Object.keys(catalog)).toHaveLength(60);
expect(Object.keys(catalog)).toHaveLength(64);
for (const [routeId, title] of Object.entries(expectedTitles)) {
expect(catalog[`route.${routeId}.title`]).toBe(title);
expect(catalog[`route.${routeId}.navigation`]).toBe(title);
@@ -0,0 +1,91 @@
// @vitest-environment jsdom
import { strict as assert } from "node:assert";
import { afterEach, test } from "vitest";
import { renderHook } from "@testing-library/react";
import { useSaveShortcut } from "../../../src/features/tech-log/presentation/studio/components/use-save-shortcut.ts";
function pressSave(overrides: Partial<KeyboardEventInit> = {}) {
const event = new KeyboardEvent("keydown", {
key: "s",
ctrlKey: true,
cancelable: true,
bubbles: true,
...overrides,
});
window.dispatchEvent(event);
return event;
}
afterEach(() => {
document.body.innerHTML = "";
});
/*
Ctrl+S "페이지를 파일로 저장".
.
.
*/
test("Ctrl+S saves and keeps the browser from offering the page as a file", () => {
let saved = 0;
const { unmount } = renderHook(() => useSaveShortcut(() => { saved += 1; }));
const event = pressSave();
assert.equal(saved, 1);
assert.equal(event.defaultPrevented, true);
unmount();
});
test("Cmd+S does the same, because the habit is the same on a Mac", () => {
let saved = 0;
const { unmount } = renderHook(() => useSaveShortcut(() => { saved += 1; }));
pressSave({ ctrlKey: false, metaKey: true });
assert.equal(saved, 1);
unmount();
});
/*
. "저장할 것이 없다"
, .
*/
test("a disabled shortcut still swallows the browser default", () => {
let saved = 0;
const { unmount } = renderHook(() =>
useSaveShortcut(() => { saved += 1; }, false),
);
const event = pressSave();
assert.equal(saved, 0);
assert.equal(event.defaultPrevented, true);
unmount();
});
test("modified chords are left to whoever else wants them", () => {
let saved = 0;
const { unmount } = renderHook(() => useSaveShortcut(() => { saved += 1; }));
const shift = pressSave({ shiftKey: true });
const alt = pressSave({ altKey: true });
const plain = pressSave({ ctrlKey: false });
assert.equal(saved, 0);
assert.equal(shift.defaultPrevented, false);
assert.equal(alt.defaultPrevented, false);
assert.equal(plain.defaultPrevented, false);
unmount();
});
test("the listener goes away with the screen", () => {
let saved = 0;
const { unmount } = renderHook(() => useSaveShortcut(() => { saved += 1; }));
unmount();
pressSave();
assert.equal(saved, 0);
});
@@ -166,8 +166,7 @@ describe("TechLog Studio project decision authoring", () => {
expect(screen.getByRole("complementary", { name: "작업 상태" }))
.toHaveTextContent("종류Decision");
await user.click(screen.getByRole("tab", { name: "즉시 미리보기" }));
const preview = screen.getByRole("tabpanel", { name: "즉시 미리보기" });
const preview = screen.getByRole("region", { name: "즉시 미리보기" });
expect(
within(preview).getByRole("heading", {
name: "목록 페이징과 컬렉션 로딩을 분리합니다",
@@ -114,20 +114,17 @@ describe("TechLog Studio document editor", () => {
]);
expect(within(rail).queryAllByRole("link")).toHaveLength(0);
const editTab = screen.getByRole("tab", { name: "편집" });
const previewTab = screen.getByRole("tab", { name: "즉시 미리보기" });
editTab.focus();
await user.keyboard("{ArrowRight}");
expect(previewTab).toHaveFocus();
expect(previewTab).toHaveAttribute("aria-selected", "true");
// 편집과 미리보기는 한 화면에 함께 있다. 탭이었을 때는 고친 결과를 보려면 편집하던 자리를
// 화면에서 치워야 했고, 돌아오면 스크롤 위치도 잃었다 — 그 왕복이 없어야 한다는 것이
// 이 화면의 요구다. 아무것도 누르지 않은 채로 둘 다 보이는지 묻는다.
expect(screen.getByRole("region", { name: "문서 편집" })).toBeVisible();
expect(
within(screen.getByRole("tabpanel", { name: "즉시 미리보기" })).getByRole(
within(screen.getByRole("region", { name: "즉시 미리보기" })).getByRole(
"heading",
{ level: 1, name: "편집한 Redis 경계" },
),
).toBeVisible();
await user.keyboard("{Home}");
expect(editTab).toHaveFocus();
expect(screen.queryByRole("tab", { name: "즉시 미리보기" })).toBeNull();
expect(screen.getByLabelText("제목")).toHaveValue("편집한 Redis 경계");
expect(calls).toEqual({ save: 0, validate: 0, preview: 0, publish: 0 });
});
@@ -79,7 +79,9 @@ describe("TechLog consumer-visible style contract", () => {
expect(rootStyle.getPropertyValue("--ink")).toBe("#17181b");
expect(rootStyle.getPropertyValue("--signal")).toBe("#3e5cc7");
expect(rootStyle.getPropertyValue("--shell")).toBe("1180px");
expect(rootStyle.getPropertyValue("--body-copy")).toBe("42rem");
// 42rem 은 영문 기준 측정값이었다. 한글 본문에서 좁았고, 바로 위의 유형/프로젝트 줄이
// shell 전체를 쓰고 있어 대비가 더 심했다. 읽는 단 전체가 이 값 하나를 따른다.
expect(rootStyle.getPropertyValue("--body-copy")).toBe("56rem");
expect(rootStyle.color).toBe("var(--ink)");
expect(rootStyle.background).toBe("var(--canvas)");
expect(bodyStyle.color).toBe("var(--ink)");
@@ -105,7 +105,8 @@ async function interact(page, action) {
} else if (action === "studio-menu") {
await page.getByRole("button", { name: "Studio 메뉴 열기" }).click();
} else if (action === "immediate-preview") {
await page.getByRole("tab", { name: "즉시 미리보기" }).click();
// 미리보기는 이제 편집 옆에 늘 떠 있다 — 열 탭이 없으므로 자리 잡기를 기다리기만 한다.
await page.getByRole("region", { name: "즉시 미리보기" }).waitFor();
} else if (action === "dirty-dialog") {
await page.getByLabel("요약").fill("저장하지 않은 시각 검증 변경");
await page.getByRole("link", { name: "게시 기록", exact: true }).first().click();
+1 -2
View File
@@ -95,8 +95,7 @@ test("Studio immediate preview matches the source without a mask", async ({ page
page,
"/studio/documents/11111111-1111-4111-8111-111111111111/edit",
);
await page.getByRole("tab", { name: "즉시 미리보기" }).click();
await expect(page.getByRole("tabpanel", { name: "즉시 미리보기" })).toBeVisible();
await expect(page.getByRole("region", { name: "즉시 미리보기" })).toBeVisible();
await expect(page).toHaveScreenshot("tech-log-studio-immediate-preview-1440.png", {
fullPage: true,
});