Compare commits

..
9 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
32 changed files with 738 additions and 225 deletions
+5 -5
View File
@@ -38,28 +38,28 @@
},
"contractSet": {
"setAlgorithm": "CA_CONTRACT_SET_V1",
"setDigest": "sha256:7832d43886cf12e6569ffd28e3f033d8a64a88e673e31449726d6dc450fa62ae",
"setDigest": "sha256:cdcfb628a502d71596f1162726eb395aad0f5f92cf05fd77d304f8e51c81b2fc",
"packages": [
{
"packageId": "@tech-log/management-contract",
"version": "1.0.0",
"digest": "sha256:72650735061fde627f5037571eb986cb758f44a546f065c88408399f8eec4a55",
"runtimeProtocolVersion": 1,
"sourceRevision": "83148b2"
"sourceRevision": "ef49d3a"
},
{
"packageId": "@tech-log/public-contract",
"version": "2.1.0",
"digest": "sha256:702d6666a8feba9899c7eb7c2a94a0880bcb23b178c7ed2009c6e69d9a1c848c",
"digest": "sha256:7eb668e39e279e49767306dd36e1dd51302071c39d78495d21307bbd9676220e",
"runtimeProtocolVersion": 1,
"sourceRevision": "83148b2"
"sourceRevision": "ef49d3a"
},
{
"packageId": "@tech-log/studio-contract",
"version": "3.1.0",
"digest": "sha256:18dd46898be64b07f7e826409d19347512613ee2e22420028a4a0644f50f37dd",
"runtimeProtocolVersion": 1,
"sourceRevision": "83148b2"
"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(
@@ -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": "83148b2",
"sourceRevision": "ef49d3a",
"operationIds": [
"createCaseDraft",
"getCaseForEdit",
@@ -1,8 +1,8 @@
{
"packageId": "@tech-log/public-contract",
"version": "2.1.0",
"digest": "sha256:702d6666a8feba9899c7eb7c2a94a0880bcb23b178c7ed2009c6e69d9a1c848c",
"sourceRevision": "83148b2",
"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:
@@ -2,7 +2,7 @@
"packageId": "@tech-log/studio-contract",
"version": "3.1.0",
"digest": "sha256:18dd46898be64b07f7e826409d19347512613ee2e22420028a4a0644f50f37dd",
"sourceRevision": "83148b2",
"sourceRevision": "ef49d3a",
"operationIds": [
"getStudioSession",
"getStudioDashboard",
@@ -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),
@@ -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;
@@ -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>
);
}
@@ -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"];
@@ -211,6 +212,12 @@ export function ProjectEditor() {
}
};
// 폼 제출과 같은 일을 하므로 submit 이벤트를 만들어 보낸다 — 검증·기본값 처리가 한 곳에 남는다.
useSaveShortcut(
() => void save({ preventDefault: () => {} } as FormEvent),
Boolean(project && draft) && !pending,
);
const published = project ? project.targetVisibility !== "PRIVATE" : false;
return (
@@ -383,7 +390,7 @@ export function ProjectEditor() {
</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">
@@ -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";
@@ -211,8 +218,16 @@ describe("TechLog canonical Public documents", () => {
*/
const [firstEvidence] = within(main).getAllByText(evidence, { exact: false });
expect(firstEvidence).toBeVisible();
expect(within(main).getByRole("navigation", { name: "문서 경로" })).toHaveTextContent(
`${kind}/${topic}/${project}`,
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]}`,
);
/*
@@ -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");
});
@@ -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 });
});
@@ -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,
});