Compare commits

...
3 Commits
Author SHA1 Message Date
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
18 changed files with 187 additions and 362 deletions
+5 -5
View File
@@ -38,28 +38,28 @@
},
"contractSet": {
"setAlgorithm": "CA_CONTRACT_SET_V1",
"setDigest": "sha256:b729150c56b64ba01d7a71a7442c14bb04d9e573b852d1d1879e748854c20174",
"setDigest": "sha256:7832d43886cf12e6569ffd28e3f033d8a64a88e673e31449726d6dc450fa62ae",
"packages": [
{
"packageId": "@tech-log/management-contract",
"version": "1.0.0",
"digest": "sha256:72650735061fde627f5037571eb986cb758f44a546f065c88408399f8eec4a55",
"runtimeProtocolVersion": 1,
"sourceRevision": "06ae075"
"sourceRevision": "83148b2"
},
{
"packageId": "@tech-log/public-contract",
"version": "2.1.0",
"digest": "sha256:702d6666a8feba9899c7eb7c2a94a0880bcb23b178c7ed2009c6e69d9a1c848c",
"runtimeProtocolVersion": 1,
"sourceRevision": "06ae075"
"sourceRevision": "83148b2"
},
{
"packageId": "@tech-log/studio-contract",
"version": "3.1.0",
"digest": "sha256:6fc015ca6727af88b7fb0088e02ba97846e1dd79fb0d4fc593cc79f2a3b9795f",
"digest": "sha256:18dd46898be64b07f7e826409d19347512613ee2e22420028a4a0644f50f37dd",
"runtimeProtocolVersion": 1,
"sourceRevision": "06ae075"
"sourceRevision": "83148b2"
}
]
}
@@ -142,17 +142,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),
);
}
@@ -2,7 +2,7 @@
"packageId": "@tech-log/management-contract",
"version": "1.0.0",
"digest": "sha256:72650735061fde627f5037571eb986cb758f44a546f065c88408399f8eec4a55",
"sourceRevision": "06ae075",
"sourceRevision": "83148b2",
"operationIds": [
"createCaseDraft",
"getCaseForEdit",
@@ -2,7 +2,7 @@
"packageId": "@tech-log/public-contract",
"version": "2.1.0",
"digest": "sha256:702d6666a8feba9899c7eb7c2a94a0880bcb23b178c7ed2009c6e69d9a1c848c",
"sourceRevision": "06ae075",
"sourceRevision": "83148b2",
"operationIds": [
"getPublicSite",
"getPublicHome",
@@ -1,8 +1,8 @@
{
"packageId": "@tech-log/studio-contract",
"version": "3.1.0",
"digest": "sha256:6fc015ca6727af88b7fb0088e02ba97846e1dd79fb0d4fc593cc79f2a3b9795f",
"sourceRevision": "06ae075",
"digest": "sha256:18dd46898be64b07f7e826409d19347512613ee2e22420028a4a0644f50f37dd",
"sourceRevision": "83148b2",
"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),
@@ -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>
);
}
@@ -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>
@@ -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;
};
@@ -114,14 +114,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 +122,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 +138,10 @@ export function ProjectEditor() {
setTopics(topicList.filter((topic) => topic.status !== "ARCHIVED"));
setRecords(catalog.items ?? []);
setError("");
},
() => {
} catch {
if (!cancelled) setError("프로젝트를 불러오지 못했습니다.");
},
);
}
})();
return () => {
cancelled = true;
};
@@ -218,88 +211,6 @@ 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);
}
};
const published = project ? project.targetVisibility !== "PRIVATE" : false;
return (
@@ -445,9 +356,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,24 +377,7 @@ 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>
@@ -490,117 +394,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 +418,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>
@@ -205,13 +205,22 @@ 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();
/*
목차가 본문의 절 제목을 그대로 다시 적으므로 같은 글이 두 자리에 나온다. 여기서 확인하려는
것은 "그 글이 문서에 있는가" 이므로 첫 자리로 충분하다.
*/
const [firstEvidence] = within(main).getAllByText(evidence, { exact: false });
expect(firstEvidence).toBeVisible();
expect(within(main).getByRole("navigation", { name: "문서 경로" })).toHaveTextContent(
`${kind}/${topic}/${project}`,
);
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 +267,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([]);
});
});
+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);
@@ -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)");