diff --git a/artifacts/tests/a11y-manual/TECH_LOG_STUDIO_RELEASE_EDIT.md b/artifacts/tests/a11y-manual/TECH_LOG_STUDIO_RELEASE_EDIT.md new file mode 100644 index 0000000..8053a79 --- /dev/null +++ b/artifacts/tests/a11y-manual/TECH_LOG_STUDIO_RELEASE_EDIT.md @@ -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. diff --git a/config/ci/gates.json b/config/ci/gates.json index 259b9ac..7629081 100644 --- a/config/ci/gates.json +++ b/config/ci/gates.json @@ -1214,6 +1214,12 @@ "schemaId": "markdown", "production": "source-controlled" }, + { + "id": "artifact-artifacts-tests-a11y-manual-TECH-LOG-STUDIO-RELEASE-EDIT-md", + "path": "artifacts/tests/a11y-manual/TECH_LOG_STUDIO_RELEASE_EDIT.md", + "schemaId": "markdown", + "production": "source-controlled" + }, { "id": "artifact-artifacts-tests-a11y-manual-TECH-LOG-STUDIO-NOT-FOUND-md", "path": "artifacts/tests/a11y-manual/TECH_LOG_STUDIO_NOT_FOUND.md", @@ -1970,6 +1976,7 @@ "artifact-artifacts-tests-a11y-manual-TECH-LOG-STUDIO-TAXONOMY-md", "artifact-artifacts-tests-a11y-manual-TECH-LOG-STUDIO-PROJECT-EDIT-md", "artifact-artifacts-tests-a11y-manual-TECH-LOG-STUDIO-RELEASES-md", + "artifact-artifacts-tests-a11y-manual-TECH-LOG-STUDIO-RELEASE-EDIT-md", "artifact-artifacts-tests-a11y-manual-TECH-LOG-STUDIO-NOT-FOUND-md", "artifact-artifacts-tests-a11y-manual-NOT-FOUND-md", "artifact-artifacts-tests-a11y-manual-report-json" diff --git a/login.png b/login.png new file mode 100644 index 0000000..5dc10c6 Binary files /dev/null and b/login.png differ diff --git a/scripts/contracts/ci-gates.ts b/scripts/contracts/ci-gates.ts index 174600a..1751c61 100644 --- a/scripts/contracts/ci-gates.ts +++ b/scripts/contracts/ci-gates.ts @@ -471,7 +471,9 @@ const CANONICAL_GATE_SHAPE_SHA256 = // 프로젝트 편집 화면: FE-GATE-009 가 TECH_LOG_STUDIO_PROJECT_EDIT 증거 아티팩트를 // 얻어 다시 계산했다. 같은 방법이다 — 187dbd96… 을 이전 gates.json 에서 먼저 재현해, // 이 값을 만든 계산이 상수가 고정될 때 쓰인 그 계산임을 확인했다. - "f9e7e521c1ea587c992f475e38f0c5f2ca39721970724529fa98678cc078a6c7"; + // 릴리즈 편집 화면: 같은 방법으로 다시 계산했다. f9e7e521… 을 이전 gates.json 에서 먼저 + // 재현했다. + "fb138e7c51fdf969f755cd8ff32cf627f1750b966d212c33ee996c8c578db0e3"; function canonicalGateShapeSha256(gates: CiGateContract["gates"]): string { 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 // a purpose, a current objective, and a next step, so the public screens that // read those fields stop rendering blanks. - if (contract.artifacts.length !== 133) { - failures.push(`artifact authority baseline must contain exactly 133 artifacts; received ${contract.artifacts.length}`); + // The release edit route followed: the editor used to open below the release + // list, so editing meant scrolling past every release to reach it. + if (contract.artifacts.length !== 134) { + failures.push(`artifact authority baseline must contain exactly 134 artifacts; received ${contract.artifacts.length}`); } if (contract.stages.length !== 5) { failures.push(`stage authority baseline must contain exactly 5 stages; received ${contract.stages.length}`); diff --git a/src/features/tech-log/contracts/tech-log-route-contract.ts b/src/features/tech-log/contracts/tech-log-route-contract.ts index 1a7a5dc..24e04f5 100644 --- a/src/features/tech-log/contracts/tech-log-route-contract.ts +++ b/src/features/tech-log/contracts/tech-log-route-contract.ts @@ -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_PROJECT_EDIT", path: "/studio/projects/:id", layoutGroup: "STUDIO", paramsSchema: "TechLogDocumentIdParams", searchSchema: null, title: "프로젝트 편집", navigationLabel: null, navigationOrder: null }), defineSpec({ routeId: "TECH_LOG_STUDIO_RELEASES", path: "/studio/releases", layoutGroup: "STUDIO", paramsSchema: null, searchSchema: null, title: "릴리즈", navigationLabel: "릴리즈", navigationOrder: 50 }), + defineSpec({ routeId: "TECH_LOG_STUDIO_RELEASE_EDIT", path: "/studio/releases/:id", layoutGroup: "STUDIO", paramsSchema: "TechLogDocumentIdParams", searchSchema: null, title: "릴리즈 편집", navigationLabel: null, navigationOrder: null }), defineSpec({ routeId: "TECH_LOG_STUDIO_NOT_FOUND", path: "/studio/*", layoutGroup: "STUDIO", paramsSchema: "TechLogStudioSplat", searchSchema: null, title: "Studio 화면을 찾을 수 없습니다", navigationLabel: null, navigationOrder: null }), defineSpec({ routeId: "NOT_FOUND", path: "*", layoutGroup: "PUBLIC", paramsSchema: "NotFoundSplat", searchSchema: null, title: "페이지를 찾을 수 없습니다.", navigationLabel: null, navigationOrder: null }), ] as const; diff --git a/src/features/tech-log/presentation/studio/components/release-editor.tsx b/src/features/tech-log/presentation/studio/components/release-editor.tsx new file mode 100644 index 0000000..0c39e6c --- /dev/null +++ b/src/features/tech-log/presentation/studio/components/release-editor.tsx @@ -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"; + +/** + * 릴리즈 한 편의 편집 화면. + * + *

예전에는 이 폼이 릴리즈 목록 아래에 열렸다. 릴리즈가 열 개 스무 개로 늘면 편집하려고 + * 목록 전체를 지나 내려가야 하고, 저장 버튼은 그보다 더 아래에 있다. 문서·프로젝트가 각자 편집 + * 주소를 갖는 것과 같은 이유로 릴리즈도 자기 주소를 갖는다. + * + *

본문이 마크다운 여섯 칸으로 나뉜 것은 계약의 모양이자 릴리즈 노트의 성격이다. 한 칸짜리 자유 + * 서술이었다면 "검증을 안 썼다"를 아무도 알아채지 못한다. + */ + +/** 백엔드 {@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(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) => + 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 ( +

+
+
+

+ RELEASE · {STATUS_LABELS[status] ?? status} +

+

{draft?.title || "릴리즈"}

+

공개 변경 기록 화면과 푸터 링크가 이 값을 그대로 읽습니다.

+
+ + 릴리즈 목록으로 + +
+ + {error ? ( +

+ {error} +

+ ) : null} + {draft === null && !error ? ( +

+ 릴리즈를 불러오는 중입니다. +

+ ) : null} + + {draft === null ? null : ( + <> +
+
+

RELEASE · {draft.versionLabel}

+

{draft.title || "릴리즈"}

+

변경 기록 화면과 푸터 링크가 이 값을 그대로 읽습니다.

+
+
+ + + +