feat: 릴리즈 편집을 자기 주소로 옮긴다

편집 폼이 릴리즈 목록 아래에 열렸다. 릴리즈가 열 개 스무 개로 늘면 편집하려고 목록
전체를 지나 내려가야 하고, 저장 버튼은 그보다 더 아래에 있다. 목록이 길어질수록 편집이
멀어지는 구조다.

`/studio/releases/:id` 를 연다 — 문서와 프로젝트가 각자 편집 주소를 갖는 것과 같은
이유다. 목록의 "편집" 은 토글이 아니라 링크가 되고, 새 릴리즈를 만들면 곧바로 그 화면으로
간다(만들자마자 편집할 것이 분명하다).

라우트가 하나 늘어 CI 게이트가 함께 움직였다 — 아티팩트 기준선 133→134, 증거 개수
112→113, 게이트 형태 다이제스트 재계산(f9e7e521… 을 이전 gates.json 에서 먼저 재현해
계산 방법을 확인했다), 서빙 패턴 하나 추가.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XEHXspz4rv5pB5wiiSsVDu
This commit is contained in:
DongHyeonka
2026-08-24 01:41:04 +09:00
co-authored by Claude Opus 5
parent 6b9dc3f0a2
commit 84d72c4f60
13 changed files with 411 additions and 122 deletions
@@ -0,0 +1,18 @@
# TECH_LOG_STUDIO_RELEASE_EDIT accessibility review
Status: pending-manual-review
Route ID: TECH_LOG_STUDIO_RELEASE_EDIT
Release ID:
Reviewer:
Reviewed at:
Signature:
Attestation: pending
M1 Keyboard: pending
M2 Visible focus: pending
M3 Route focus: pending
M4 Modal focus: pending
M5 Error association: pending
M6 Color signal: pending
M7 Reduced motion: pending
Screen reader: pending
Notes: Human review pending; automated evidence does not replace signed keyboard, focus, and screen-reader review.
+7
View File
@@ -1214,6 +1214,12 @@
"schemaId": "markdown", "schemaId": "markdown",
"production": "source-controlled" "production": "source-controlled"
}, },
{
"id": "artifact-artifacts-tests-a11y-manual-TECH-LOG-STUDIO-RELEASE-EDIT-md",
"path": "artifacts/tests/a11y-manual/TECH_LOG_STUDIO_RELEASE_EDIT.md",
"schemaId": "markdown",
"production": "source-controlled"
},
{ {
"id": "artifact-artifacts-tests-a11y-manual-TECH-LOG-STUDIO-NOT-FOUND-md", "id": "artifact-artifacts-tests-a11y-manual-TECH-LOG-STUDIO-NOT-FOUND-md",
"path": "artifacts/tests/a11y-manual/TECH_LOG_STUDIO_NOT_FOUND.md", "path": "artifacts/tests/a11y-manual/TECH_LOG_STUDIO_NOT_FOUND.md",
@@ -1970,6 +1976,7 @@
"artifact-artifacts-tests-a11y-manual-TECH-LOG-STUDIO-TAXONOMY-md", "artifact-artifacts-tests-a11y-manual-TECH-LOG-STUDIO-TAXONOMY-md",
"artifact-artifacts-tests-a11y-manual-TECH-LOG-STUDIO-PROJECT-EDIT-md", "artifact-artifacts-tests-a11y-manual-TECH-LOG-STUDIO-PROJECT-EDIT-md",
"artifact-artifacts-tests-a11y-manual-TECH-LOG-STUDIO-RELEASES-md", "artifact-artifacts-tests-a11y-manual-TECH-LOG-STUDIO-RELEASES-md",
"artifact-artifacts-tests-a11y-manual-TECH-LOG-STUDIO-RELEASE-EDIT-md",
"artifact-artifacts-tests-a11y-manual-TECH-LOG-STUDIO-NOT-FOUND-md", "artifact-artifacts-tests-a11y-manual-TECH-LOG-STUDIO-NOT-FOUND-md",
"artifact-artifacts-tests-a11y-manual-NOT-FOUND-md", "artifact-artifacts-tests-a11y-manual-NOT-FOUND-md",
"artifact-artifacts-tests-a11y-manual-report-json" "artifact-artifacts-tests-a11y-manual-report-json"
BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 27 KiB

+7 -3
View File
@@ -471,7 +471,9 @@ const CANONICAL_GATE_SHAPE_SHA256 =
// 프로젝트 편집 화면: FE-GATE-009 가 TECH_LOG_STUDIO_PROJECT_EDIT 증거 아티팩트를 // 프로젝트 편집 화면: FE-GATE-009 가 TECH_LOG_STUDIO_PROJECT_EDIT 증거 아티팩트를
// 얻어 다시 계산했다. 같은 방법이다 — 187dbd96… 을 이전 gates.json 에서 먼저 재현해, // 얻어 다시 계산했다. 같은 방법이다 — 187dbd96… 을 이전 gates.json 에서 먼저 재현해,
// 이 값을 만든 계산이 상수가 고정될 때 쓰인 그 계산임을 확인했다. // 이 값을 만든 계산이 상수가 고정될 때 쓰인 그 계산임을 확인했다.
"f9e7e521c1ea587c992f475e38f0c5f2ca39721970724529fa98678cc078a6c7"; // 릴리즈 편집 화면: 같은 방법으로 다시 계산했다. f9e7e521… 을 이전 gates.json 에서 먼저
// 재현했다.
"fb138e7c51fdf969f755cd8ff32cf627f1750b966d212c33ee996c8c578db0e3";
function canonicalGateShapeSha256(gates: CiGateContract["gates"]): string { function canonicalGateShapeSha256(gates: CiGateContract["gates"]): string {
const normalized = gates.map( const normalized = gates.map(
@@ -529,8 +531,10 @@ function canonicalAuthorityBaselineFailures(contract: CiGateContract): string[]
// The project edit route did the same: it is what finally lets a project carry // The project edit route did the same: it is what finally lets a project carry
// a purpose, a current objective, and a next step, so the public screens that // a purpose, a current objective, and a next step, so the public screens that
// read those fields stop rendering blanks. // read those fields stop rendering blanks.
if (contract.artifacts.length !== 133) { // The release edit route followed: the editor used to open below the release
failures.push(`artifact authority baseline must contain exactly 133 artifacts; received ${contract.artifacts.length}`); // list, so editing meant scrolling past every release to reach it.
if (contract.artifacts.length !== 134) {
failures.push(`artifact authority baseline must contain exactly 134 artifacts; received ${contract.artifacts.length}`);
} }
if (contract.stages.length !== 5) { if (contract.stages.length !== 5) {
failures.push(`stage authority baseline must contain exactly 5 stages; received ${contract.stages.length}`); failures.push(`stage authority baseline must contain exactly 5 stages; received ${contract.stages.length}`);
@@ -51,6 +51,7 @@ const TECH_LOG_ROUTE_SPECS = [
defineSpec({ routeId: "TECH_LOG_STUDIO_TAXONOMY", path: "/studio/taxonomy", layoutGroup: "STUDIO", paramsSchema: null, searchSchema: null, title: "주제와 프로젝트", navigationLabel: "주제·프로젝트", navigationOrder: 40 }), defineSpec({ routeId: "TECH_LOG_STUDIO_TAXONOMY", path: "/studio/taxonomy", layoutGroup: "STUDIO", paramsSchema: null, searchSchema: null, title: "주제와 프로젝트", navigationLabel: "주제·프로젝트", navigationOrder: 40 }),
defineSpec({ routeId: "TECH_LOG_STUDIO_PROJECT_EDIT", path: "/studio/projects/:id", layoutGroup: "STUDIO", paramsSchema: "TechLogDocumentIdParams", searchSchema: null, title: "프로젝트 편집", navigationLabel: null, navigationOrder: null }), defineSpec({ routeId: "TECH_LOG_STUDIO_PROJECT_EDIT", path: "/studio/projects/:id", layoutGroup: "STUDIO", paramsSchema: "TechLogDocumentIdParams", searchSchema: null, title: "프로젝트 편집", navigationLabel: null, navigationOrder: null }),
defineSpec({ routeId: "TECH_LOG_STUDIO_RELEASES", path: "/studio/releases", layoutGroup: "STUDIO", paramsSchema: null, searchSchema: null, title: "릴리즈", navigationLabel: "릴리즈", navigationOrder: 50 }), defineSpec({ routeId: "TECH_LOG_STUDIO_RELEASES", path: "/studio/releases", layoutGroup: "STUDIO", paramsSchema: null, searchSchema: null, title: "릴리즈", navigationLabel: "릴리즈", navigationOrder: 50 }),
defineSpec({ routeId: "TECH_LOG_STUDIO_RELEASE_EDIT", path: "/studio/releases/:id", layoutGroup: "STUDIO", paramsSchema: "TechLogDocumentIdParams", searchSchema: null, title: "릴리즈 편집", navigationLabel: null, navigationOrder: null }),
defineSpec({ routeId: "TECH_LOG_STUDIO_NOT_FOUND", path: "/studio/*", layoutGroup: "STUDIO", paramsSchema: "TechLogStudioSplat", searchSchema: null, title: "Studio 화면을 찾을 수 없습니다", navigationLabel: null, navigationOrder: null }), defineSpec({ routeId: "TECH_LOG_STUDIO_NOT_FOUND", path: "/studio/*", layoutGroup: "STUDIO", paramsSchema: "TechLogStudioSplat", searchSchema: null, title: "Studio 화면을 찾을 수 없습니다", navigationLabel: null, navigationOrder: null }),
defineSpec({ routeId: "NOT_FOUND", path: "*", layoutGroup: "PUBLIC", paramsSchema: "NotFoundSplat", searchSchema: null, title: "페이지를 찾을 수 없습니다.", navigationLabel: null, navigationOrder: null }), defineSpec({ routeId: "NOT_FOUND", path: "*", layoutGroup: "PUBLIC", paramsSchema: "NotFoundSplat", searchSchema: null, title: "페이지를 찾을 수 없습니다.", navigationLabel: null, navigationOrder: null }),
] as const; ] as const;
@@ -0,0 +1,355 @@
import { useCallback, useEffect, useState } from "react";
import type {
ReleaseEditResponse,
ReleaseUpdateRequest,
} from "../../../contracts/management/contract.ts";
import { managementFailureMessage } from "../../../application/ports/management-gateway-error.ts";
import { useRouteInput } from "../../../../../presentation/routes/route-input.tsx";
import { GuardedStudioLink } from "./guarded-studio-link.tsx";
import { useStudio } from "../use-studio.ts";
/**
* 릴리즈 한 편의 편집 화면.
*
* <p>예전에는 이 폼이 릴리즈 목록 <em>아래</em>에 열렸다. 릴리즈가 열 개 스무 개로 늘면 편집하려고
* 목록 전체를 지나 내려가야 하고, 저장 버튼은 그보다 더 아래에 있다. 문서·프로젝트가 각자 편집
* 주소를 갖는 것과 같은 이유로 릴리즈도 자기 주소를 갖는다.
*
* <p>본문이 마크다운 여섯 칸으로 나뉜 것은 계약의 모양이자 릴리즈 노트의 성격이다. 한 칸짜리 자유
* 서술이었다면 "검증을 안 썼다"를 아무도 알아채지 못한다.
*/
/** 백엔드 {@code UpdateReleaseUseCase.CHANGE_TYPES} 와 같은 집합이다. */
const CHANGE_TYPES = [
{ value: "FEATURE", label: "기능" },
{ value: "FIX", label: "수정" },
{ value: "REFACTOR", label: "구조" },
{ value: "DOCS", label: "문서" },
{ value: "INFRA", label: "인프라" },
{ value: "BREAKING", label: "호환 깨짐" },
] as const;
const SECTIONS = [
{ key: "reasonMarkdown", label: "왜 바꿨나" },
{ key: "changesMarkdown", label: "무엇을 바꿨나" },
{ key: "userImpactMarkdown", label: "사용자에게 달라지는 것" },
{ key: "implementationImpactMarkdown", label: "구현에 남는 것" },
{ key: "verificationMarkdown", label: "어떻게 검증했나" },
{ key: "knownLimitationsMarkdown", label: "아직 못 한 것" },
] as const;
type Draft = Readonly<{
expectedVersion: number;
versionLabel: string;
title: string;
summary: string;
releasedOn: string;
changeTypes: readonly string[];
reasonMarkdown: string;
changesMarkdown: string;
userImpactMarkdown: string;
implementationImpactMarkdown: string;
verificationMarkdown: string;
knownLimitationsMarkdown: string;
}>;
function toDraft(release: ReleaseEditResponse): Draft {
return {
expectedVersion: release.version,
versionLabel: release.versionLabel,
title: release.title,
summary: release.summary ?? "",
releasedOn: release.releasedOn ?? "",
changeTypes: release.changeTypes ?? [],
reasonMarkdown: release.reasonMarkdown ?? "",
changesMarkdown: release.changesMarkdown ?? "",
userImpactMarkdown: release.userImpactMarkdown ?? "",
implementationImpactMarkdown: release.implementationImpactMarkdown ?? "",
verificationMarkdown: release.verificationMarkdown ?? "",
knownLimitationsMarkdown: release.knownLimitationsMarkdown ?? "",
};
}
export function ReleaseEditor() {
const { params } = useRouteInput<"TECH_LOG_STUDIO_RELEASE_EDIT">();
const releaseId = params.id;
const { managementGateway, setRequestAnnouncement } = useStudio();
const [draft, setDraft] = useState<Draft | null>(null);
const [status, setStatus] = useState("");
const [pending, setPending] = useState(false);
const [error, setError] = useState("");
const [generation, setGeneration] = useState(0);
const reload = useCallback(() => setGeneration((value) => value + 1), []);
useEffect(() => {
let cancelled = false;
void managementGateway.getRelease(releaseId).then(
(release) => {
if (cancelled) return;
setDraft(toDraft(release));
setStatus(release.workflowStatus);
setError("");
},
() => {
if (!cancelled) setError("릴리즈를 불러오지 못했습니다.");
},
);
return () => {
cancelled = true;
};
}, [managementGateway, releaseId, generation]);
const update = (patch: Partial<Draft>) =>
setDraft((current) => (current === null ? current : { ...current, ...patch }));
const requestOf = (current: Draft): ReleaseUpdateRequest =>
({
expectedVersion: current.expectedVersion,
versionLabel: current.versionLabel.trim(),
title: current.title.trim(),
summary: current.summary,
changeTypes: [...current.changeTypes],
changesMarkdown: current.changesMarkdown,
verificationMarkdown: current.verificationMarkdown,
...(current.releasedOn ? { releasedOn: current.releasedOn } : {}),
reasonMarkdown: current.reasonMarkdown,
userImpactMarkdown: current.userImpactMarkdown,
implementationImpactMarkdown: current.implementationImpactMarkdown,
knownLimitationsMarkdown: current.knownLimitationsMarkdown,
}) as ReleaseUpdateRequest;
const save = async () => {
if (pending || draft === null) return;
setPending(true);
setError("");
try {
const saved = await managementGateway.updateRelease(releaseId, requestOf(draft));
setDraft(toDraft(saved));
setRequestAnnouncement(`릴리즈 ${saved.versionLabel} 을(를) 저장했습니다.`);
reload();
} catch (failure) {
setError(managementFailureMessage(failure, "저장하지 못했습니다."));
} finally {
setPending(false);
}
};
const publish = async () => {
if (pending || draft === null) return;
setPending(true);
setError("");
try {
/*
먼저 저장한다. 발행은 서버에 저장된 릴리즈를 검사하는데(`PublishReleaseUseCase`), 예전에는
저장하지 않고 발행만 불렀다 — 화면의 칸을 다 채우고 공개를 눌러도 서버 쪽은 여전히 빈
초안이라 "모두 채워져야 합니다" 가 떴다. 채웠는데 안 된다는 말이 나온 이유가 이것이다.
*/
const saved = await managementGateway.updateRelease(releaseId, requestOf(draft));
setDraft(toDraft(saved));
const published = await managementGateway.publishRelease(releaseId, saved.version);
setRequestAnnouncement(`릴리즈를 공개했습니다: ${published.canonicalPath}`);
reload();
} catch (failure) {
// 무엇이 모자란지는 서버가 안다. 예전에는 그 답을 버리고 필수 항목을 전부 나열했는데,
// 그러면 이미 채운 칸까지 비었다고 말하게 된다.
setError(
managementFailureMessage(
failure,
"공개하지 못했습니다. 버전, 제목, 한 줄 요약, 변경 유형, 변경 내용, 검증, 공개일이 모두 채워져야 합니다.",
),
);
} finally {
setPending(false);
}
};
const archive = async () => {
if (pending || draft === null) return;
setPending(true);
setError("");
try {
await managementGateway.archiveRelease(releaseId, draft.expectedVersion);
setRequestAnnouncement("릴리즈를 공개에서 내렸습니다.");
reload();
} catch (failure) {
setError(managementFailureMessage(failure, "공개에서 내리지 못했습니다."));
} finally {
setPending(false);
}
};
const toggleChangeType = (value: string) => {
if (draft === null) return;
update({
changeTypes: draft.changeTypes.includes(value)
? draft.changeTypes.filter((entry) => entry !== value)
: [...draft.changeTypes, value],
});
};
return (
<div className="studio-page studio-editor-page">
<header className="studio-page-top">
<div className="studio-page-heading">
<p className="studio-eyebrow">
RELEASE · {STATUS_LABELS[status] ?? status}
</p>
<h1>{draft?.title || "릴리즈"}</h1>
<p> .</p>
</div>
<GuardedStudioLink className="studio-primary-action" href="/studio/releases">
</GuardedStudioLink>
</header>
{error ? (
<p className="studio-screen-error" role="alert">
{error}
</p>
) : null}
{draft === null && !error ? (
<p className="studio-loading" role="status">
.
</p>
) : null}
{draft === null ? null : (
<>
<section className="studio-editor-section" aria-labelledby="studio-release-edit-title">
<div className="studio-editor-section-heading">
<p className="studio-eyebrow">RELEASE · {draft.versionLabel}</p>
<h2 id="studio-release-edit-title">{draft.title || "릴리즈"}</h2>
<p> .</p>
</div>
<div className="studio-field-grid">
<label className="studio-field">
<span></span>
<input
type="text"
value={draft.versionLabel}
placeholder="0.1.0"
onChange={(event) => update({ versionLabel: event.currentTarget.value })}
/>
{/*
새 릴리즈는 `draft-…` 라는 자리표시자 버전으로 만들어지고, 서버는 그것이 남아
있으면 공개를 거절한다(`ReleaseDrafts.isPlaceholder`). 화면에는 값이 채워져
보이므로 왜 거절당하는지 알 길이 없었다.
*/}
{draft.versionLabel.startsWith("draft-") ? (
<span className="studio-field-notice studio-field-notice--error" role="alert">
. (: 0.2.0).
</span>
) : null}
</label>
<label className="studio-field">
<span></span>
<input
type="text"
value={draft.title}
onChange={(event) => update({ title: event.currentTarget.value })}
/>
</label>
<label className="studio-field">
<span></span>
<input
type="date"
value={draft.releasedOn}
onChange={(event) => update({ releasedOn: event.currentTarget.value })}
/>
</label>
<label className="studio-field studio-field--wide">
<span> </span>
<textarea
value={draft.summary}
onChange={(event) => update({ summary: event.currentTarget.value })}
/>
</label>
</div>
<fieldset className="studio-checkbox-set">
<legend> </legend>
{CHANGE_TYPES.map((changeType) => (
<label key={changeType.value} className="studio-field studio-field--checkbox">
<input
type="checkbox"
checked={draft.changeTypes.includes(changeType.value)}
onChange={() => toggleChangeType(changeType.value)}
/>
<span>{changeType.label}</span>
</label>
))}
</fieldset>
<div className="studio-field-grid">
{SECTIONS.map((section) => (
<label key={section.key} className="studio-field studio-field--wide">
<span>{section.label}</span>
<textarea
value={draft[section.key]}
onChange={(event) => update({ [section.key]: event.currentTarget.value })}
/>
</label>
))}
</div>
<div className="studio-create-footer">
<button
className="studio-primary-button"
type="button"
disabled={pending}
onClick={() => void save()}
>
</button>
<button
className="studio-secondary-button"
type="button"
disabled={pending}
onClick={() => void publish()}
>
</button>
<button
className="studio-secondary-button"
type="button"
disabled={pending}
onClick={() => void archive()}
>
</button>
</div>
<div className="studio-create-footer">
<button
className="studio-primary-button"
type="button"
disabled={pending}
onClick={() => void save()}
>
</button>
<button
className="studio-secondary-button"
type="button"
disabled={pending}
onClick={() => void publish()}
>
</button>
<button
className="studio-secondary-button"
type="button"
disabled={pending}
onClick={() => void archive()}
>
</button>
</div>
</section>
</>
)}
</div>
);
}
@@ -149,8 +149,8 @@ export function ReleaseManager() {
const created = await managementGateway.createRelease(title); const created = await managementGateway.createRelease(title);
setNewTitle(""); setNewTitle("");
setRequestAnnouncement(`릴리즈 ${title} 초안을 만들었습니다.`); setRequestAnnouncement(`릴리즈 ${title} 초안을 만들었습니다.`);
setSelectedId(created.id); // 만들자마자 편집할 것이 분명하므로 그 화면으로 보낸다.
reload(); navigate(`/studio/releases/${created.id}`);
} catch (error) { } catch (error) {
setError(managementFailureMessage(error, "릴리즈를 만들지 못했습니다.")); setError(managementFailureMessage(error, "릴리즈를 만들지 못했습니다."));
} finally { } finally {
@@ -240,7 +240,6 @@ export function ReleaseManager() {
setError(""); setError("");
try { try {
await managementGateway.deleteRelease(release.id, release.version); await managementGateway.deleteRelease(release.id, release.version);
if (selectedId === release.id) setSelectedId(null);
setRequestAnnouncement(`릴리즈 ${release.versionLabel} 을(를) 삭제했습니다.`); setRequestAnnouncement(`릴리즈 ${release.versionLabel} 을(를) 삭제했습니다.`);
reload(); reload();
} catch (error) { } catch (error) {
@@ -304,7 +303,6 @@ export function ReleaseManager() {
<article <article
key={release.id} key={release.id}
className="studio-document-row" className="studio-document-row"
aria-current={selectedId === release.id ? "true" : undefined}
> >
{/* {/*
첫 칸의 유형 라벨은 목록 격자의 첫 열이다. 빠뜨리면 제목이 그 자리로 당겨져 첫 칸의 유형 라벨은 목록 격자의 첫 열이다. 빠뜨리면 제목이 그 자리로 당겨져
@@ -330,14 +328,12 @@ export function ReleaseManager() {
</div> </div>
</dl> </dl>
<div className="studio-row-actions"> <div className="studio-row-actions">
<button <GuardedStudioLink
className="studio-secondary-button" className="studio-secondary-button"
type="button" href={`/studio/releases/${release.id}`}
disabled={pending}
onClick={() => setSelectedId(selectedId === release.id ? null : release.id)}
> >
{selectedId === release.id ? "닫기" : "편집"}
</button> </GuardedStudioLink>
<button <button
className="studio-secondary-button" className="studio-secondary-button"
type="button" type="button"
@@ -353,112 +349,6 @@ export function ReleaseManager() {
</section> </section>
)} )}
{draft === null ? null : (
<section className="studio-editor-section" aria-labelledby="studio-release-edit-title">
<div className="studio-editor-section-heading">
<p className="studio-eyebrow">RELEASE · {draft.versionLabel}</p>
<h2 id="studio-release-edit-title">{draft.title || "릴리즈"}</h2>
<p> .</p>
</div>
<div className="studio-field-grid">
<label className="studio-field">
<span></span>
<input
type="text"
value={draft.versionLabel}
placeholder="0.1.0"
onChange={(event) => update({ versionLabel: event.currentTarget.value })}
/>
{/*
새 릴리즈는 `draft-…` 라는 자리표시자 버전으로 만들어지고, 서버는 그것이 남아
있으면 공개를 거절한다(`ReleaseDrafts.isPlaceholder`). 화면에는 값이 채워져
보이므로 왜 거절당하는지 알 길이 없었다.
*/}
{draft.versionLabel.startsWith("draft-") ? (
<span className="studio-field-notice studio-field-notice--error" role="alert">
. (: 0.2.0).
</span>
) : null}
</label>
<label className="studio-field">
<span></span>
<input
type="text"
value={draft.title}
onChange={(event) => update({ title: event.currentTarget.value })}
/>
</label>
<label className="studio-field">
<span></span>
<input
type="date"
value={draft.releasedOn}
onChange={(event) => update({ releasedOn: event.currentTarget.value })}
/>
</label>
<label className="studio-field studio-field--wide">
<span> </span>
<textarea
value={draft.summary}
onChange={(event) => update({ summary: event.currentTarget.value })}
/>
</label>
</div>
<fieldset className="studio-checkbox-set">
<legend> </legend>
{CHANGE_TYPES.map((changeType) => (
<label key={changeType.value} className="studio-field studio-field--checkbox">
<input
type="checkbox"
checked={draft.changeTypes.includes(changeType.value)}
onChange={() => toggleChangeType(changeType.value)}
/>
<span>{changeType.label}</span>
</label>
))}
</fieldset>
<div className="studio-field-grid">
{SECTIONS.map((section) => (
<label key={section.key} className="studio-field studio-field--wide">
<span>{section.label}</span>
<textarea
value={draft[section.key]}
onChange={(event) => update({ [section.key]: event.currentTarget.value })}
/>
</label>
))}
</div>
<div className="studio-create-footer">
<button
className="studio-primary-button"
type="button"
disabled={pending}
onClick={() => void save()}
>
</button>
<button
className="studio-secondary-button"
type="button"
disabled={pending}
onClick={() => void publish()}
>
</button>
<button
className="studio-secondary-button"
type="button"
disabled={pending}
onClick={() => void archive()}
>
</button>
</div>
</section>
)}
</div> </div>
); );
} }
@@ -0,0 +1,5 @@
import { ReleaseEditor } from "../components/release-editor.tsx";
export function ReleaseEditPage() {
return <ReleaseEditor />;
}
@@ -230,6 +230,13 @@ export const TECH_LOG_ROUTE_RUNTIME = Object.freeze({
"StudioReleasesPage", "StudioReleasesPage",
), ),
), ),
TECH_LOG_STUDIO_RELEASE_EDIT: runtime(
"TECH_LOG_STUDIO_RELEASE_EDIT",
routeModule(
() => import("./studio/pages/release-edit-page.tsx"),
"ReleaseEditPage",
),
),
TECH_LOG_STUDIO_NOT_FOUND: runtime( TECH_LOG_STUDIO_NOT_FOUND: runtime(
"TECH_LOG_STUDIO_NOT_FOUND", "TECH_LOG_STUDIO_NOT_FOUND",
routeModule( routeModule(
+2 -2
View File
@@ -200,8 +200,8 @@ describe("CI gate contract", () => {
// than taking either side's number. // than taking either side's number.
// Task 11 added TECH_LOG_STUDIO_ASSETS's manual accessibility evidence. // Task 11 added TECH_LOG_STUDIO_ASSETS's manual accessibility evidence.
// Alignment follow-up item 2 added the TechLog junit report. // Alignment follow-up item 2 added the TechLog junit report.
expect(contract.gates.reduce((total, gate) => total + gate.evidenceArtifactIds.length, 0)).toBe(112); expect(contract.gates.reduce((total, gate) => total + gate.evidenceArtifactIds.length, 0)).toBe(113);
expect(contract.artifacts).toHaveLength(133); expect(contract.artifacts).toHaveLength(134);
expect(contract.stages).toHaveLength(5); expect(contract.stages).toHaveLength(5);
expect(contract.retention.classes).toHaveLength(5); expect(contract.retention.classes).toHaveLength(5);
expect(index.gates.get("FE-GATE-015")?.commandIds).toHaveLength(2); expect(index.gates.get("FE-GATE-015")?.commandIds).toHaveLength(2);
@@ -192,7 +192,7 @@ describe("selective Task 3 contract closure", () => {
expect(canonical.gates).toHaveLength(27); expect(canonical.gates).toHaveLength(27);
expect(canonical.commands).toHaveLength(85); expect(canonical.commands).toHaveLength(85);
expect(canonical.gates.reduce((sum, gate) => sum + gate.commandIds.length, 0)).toBe(97); expect(canonical.gates.reduce((sum, gate) => sum + gate.commandIds.length, 0)).toBe(97);
expect(canonical.artifacts).toHaveLength(133); expect(canonical.artifacts).toHaveLength(134);
expect(canonical.stages).toHaveLength(5); expect(canonical.stages).toHaveLength(5);
expect(canonical.retention.classes).toHaveLength(5); expect(canonical.retention.classes).toHaveLength(5);
@@ -52,6 +52,7 @@ describe("TechLog production serving contract", () => {
"^/studio/publications$", "^/studio/publications$",
"^/studio/publications/[^/]+/preview$", "^/studio/publications/[^/]+/preview$",
"^/studio/releases$", "^/studio/releases$",
"^/studio/releases/[^/]+$",
"^/studio/taxonomy$", "^/studio/taxonomy$",
]); ]);
expect(contract.notFound).toEqual({ expect(contract.notFound).toEqual({
+1
View File
@@ -34,6 +34,7 @@ const techLogRouteChunks = Object.freeze({
"/studio/pages/taxonomy-page.tsx": "route-tech-log-studio-taxonomy", "/studio/pages/taxonomy-page.tsx": "route-tech-log-studio-taxonomy",
"/studio/pages/project-edit-page.tsx": "route-tech-log-studio-project-edit", "/studio/pages/project-edit-page.tsx": "route-tech-log-studio-project-edit",
"/studio/pages/releases-page.tsx": "route-tech-log-studio-releases", "/studio/pages/releases-page.tsx": "route-tech-log-studio-releases",
"/studio/pages/release-edit-page.tsx": "route-tech-log-studio-release-edit",
"/studio/pages/studio-not-found-page.tsx": "route-tech-log-studio-not-found", "/studio/pages/studio-not-found-page.tsx": "route-tech-log-studio-not-found",
"/public/pages/public-not-found-page.tsx": "route-not-found", "/public/pages/public-not-found-page.tsx": "route-not-found",
} as const); } as const);