WorkoutFlow Props Contract
화면 픽스처(앱 저장소):
src/react/ui/mobile/fixtures/WorkoutFlow.fixture.ts
Updated: 2026-09-08
세 번째 화면 계약 (운동 플로우). Claude는 프레젠테이션(src/react/ui/screens/WorkoutFlow.jsx, export window.UiWorkoutFlow)을 소유. Codex는 컨테이너·영속화·Supabase 소유.
핵심 경계 (중요)
운동 진행 중인 드래프트(세트 입력·휴식·RPE·종목완료 등)는 전부 UI-local 상태다. 컨테이너는 시작 데이터만 주입하고 완성된 결과만 콜백으로 회신받는다. 세트 하나하나를 컨테이너로 올리지 않는다.
src/react/ui/screens/WorkoutFlow.jsx (export: window.UiWorkoutFlow)
src/react/ui/fixtures/workoutFixture.js (preview data = 계약 형태)Props
<UiWorkoutFlow
mode={mode} // "session" | "plan"
today={today} // "2026-06-09"
date={date} // 대상 날짜 ISO
plansForDate={plansForDate} // [PlanLite] start: 그날 예정 세션 불러오기
initialDraft={initialDraft} // Draft | null (불러오기/이어하기 초기값; null = 빈 세션)
initialSnapshot={initialSnapshot} // WorkoutSnapshot | null (재진입 복귀용 — step·휴식타이머까지 복원)
exerciseCatalog={exerciseCatalog} // [Exercise] 종목 선택 시트
exerciseSearchSignals={signals} // ExerciseSearchSignals | null (선택, 이슈 #1101) — 종목 선택 시트의 결과 순서 신호(내 최근 수행일·종목별 사용자 수). null이면 인기 등급만으로 정렬
oneRms={oneRms} // { [exerciseId|종목명]: kg } (선택, 2026-07-30) — 편집 옵션바 kg/lb/% 토글의 % 모드(1RM 대비 입력) 활성화. 데스크톱 PlanEditor와 동일 느슐 매칭. 미주입 시 % 버튼 비노출
exerciseHistory={exerciseHistory} // { [exerciseId|종목명]: [{ date: 표시용 문자열(ISO 권장), sets: [{type,load,reps}], isPR?, note? }] } (선택, 2026-07-30) — 편집 옵션바 "훈련 기록" 드로어(최근순 최대 15건, 기록 상세 E 훈련 목록 rc-feed 포맷). 미주입 시 버튼 비노출
defaultSessionName={defaultName} // 빈 세션 기본 이름 (예: "금요일 저녁 운동")
onSaveSession={onSaveSession} // (session: SessionFull) => MaybePromise mode=session 종료 저장
onAutoSaveSession={onAutoSaveSession} // (session: SessionFull) => MaybePromise<{sessionId, serverRevision?}> (이슈 #1017) — 라이브 신규 기록이 finish 스텝에 들어온 즉시 1회 호출되는 자동 저장. 저장 후에도 운동 레이어는 유지. 미주입/실패 시 종전의 버튼 수동 저장으로 폴백
onDeleteSavedSession={onDeleteSavedSession} // ({ id }) => MaybePromise (이슈 #1017) — finish의 "기록 삭제": 자동 저장된 세션을 서버에서 삭제하고 레이어를 닫는다
onSavePlan={onSavePlan} // (plan: PlanDraft) => MaybePromise mode=plan 계획 저장
onDraftSnapshot={onDraftSnapshot} // (snapshot: WorkoutSnapshot) => void 복귀 안전용 임시저장: step 이동·탭 이탈·10~20s debounce 때 호출
onDiscard={onDiscard} // (options?: { cause?: "empty-live-finish"|"empty-record-finish" }) => MaybePromise 저장 안 하고 폐기/삭제 — cause = 빈 세션 종료(2026-08-24), 컨테이너가 표면별 토스트 선택
onExit={onExit} // () => void 메인으로 (저장 없이 빠져나가기)
onAddCustomExercise={onAddCustomExercise} // (m: Exercise) => MaybePromise 커스텀 종목 추가(선택)
/>Types
type MaybePromise<T> = T | Promise<T>;
type SetType = "warmup" | "main" | "top"; // 웜업 | 메인 | 다운
type Difficulty = "쉬웠음" | "적당함" | "빡셌음" | "겨우함" | "실패함";
type Exercise = { id: string; name: string; nameEn: string; equip: "barbell"|"dumbbell"|"machine"|"bodyweight"; part: string };
// ⚠ 컨테이너(Codex)가 현재 데이터(en, cat, equip:"바벨"…)를 **이 형태로 normalize**해 주입 → ui는 깔끈한 타입 유지
type DraftSet = {
id: string;
type: SetType;
load: number; reps: number; // 입력값 (load = kg 칸로니컬)
loadLb?: number | null; // lb 모드 원본 (kg은 파생값, 0.1 반올림)
loadPct?: number | null; // % 모드 원본(1RM 대비 %) — 저장·재편집 왕복 (2026-07-30)
done: boolean; // 완료 체크
perceivedRpe?: number | null; // RPE 1.0~10.0(완료 후, 이슈 #1237) — 완료 카드에는 단축 버튼만 표시(2026-09-08), 기존 소수 값·세트 수정 컴포저 입력·저장 범위는 보존
memo?: string; // 세트 리뷰
restSeconds?: number | null; // 휴식 시간(초). null = 미기록 (Supabase rest_seconds와 일치)
freeRest?: boolean; // true = 자유 휴식(시간 미측정)
};
type DraftExercise = {
id: string; name: string;
sets: DraftSet[];
done: boolean; // 종목 완료
review?: string; // 종목 리뷰
};
type Draft = {
title: string;
condition?: string; // 운동 전 컨디션 — 기능 폐기(2026-08-24): 입력·표시 UI 없음, 기존 세션 값 보존용 통과 필드
preNote?: string; // 시작 전 메모
startTime?: string; // "HH:mm"
exercises: DraftExercise[];
};
// 복귀용 스냅샷 — 드래프트 + 화면 상태(휴식 타이머 복원에 필요)
type WorkoutSnapshot = {
step: "start" | "record" | "finish";
draft: Draft;
activeExerciseId?: string | null;
activeSetIndex?: number | null;
restTimer?: { exerciseId: string; setIndex: number; startedAt: number } | null; // startedAt = epoch ms
};
type PlanLite = {
id: string;
title: string;
exCount: number;
setCount: number;
draft?: Draft; // 다중 계획 정확도용. 선택된 PlanLite의 draft가 시작 데이터가 된다.
};
// 다중 계획 규칙:
// - start step에서 계획을 선택하면 UI는 selected PlanLite.draft를 우선 사용한다.
// - draft가 없는 경우에만 top-level initialDraft로 폴백한다.
// - Codex 컨테이너는 plansForDate의 각 항목에 해당 계획의 draft를 실어야 한다.
// 그래야 같은 날짜에 여러 계획이 있어도 선택한 계획 그대로 운동을 시작할 수 있다.
// 계획용은 기록 필드(done/perceivedRpe/rest/memo) 없이 입력값만 — 운동 기록과 분리
type PlanSet = { id: string; type: SetType; load: number; reps: number };
type PlanExercise = { id: string; name: string; sets: PlanSet[] };
type PlanDraft = { id?: string; date: string; title: string; exercises: PlanExercise[] };
// 종료 저장 payload — 세션 화면 계약의 SessionFull과 동일 + 운동 저장 필수 필드 명시
type SessionFull = {
id?: string;
plannedSessionId?: string; // 예정 세션을 불러와 완료한 경우 원본 id (상태 전이용)
date: string;
title: string;
condition?: string; // 운동 전 컨디션 — 기능 폐기(2026-08-24): UI 없음, 기존 값 보존용 통과 필드
postCondition?: string; // 운동 후 컨디션 — 기능 폐기(2026-08-24): UI 없음, 기존 값 보존용 통과 필드
preNote?: string; // 시작 전 메모
note?: string; // 세션 리뷰
startTime?: string; // "HH:mm"
endTime?: string; // "HH:mm"
duration?: string | number; // 총 운동 시간 (라벨 또는 분)
exercises: DraftExercise[]; // 수행 결과 (done/perceivedRpe/rest/memo/review 포함)
// 그 외 표시용 필드(summary, newPRs 등)는 저장 후 도메인에서 계산 — 세션 계약 참조
};단계(step) — UI-local
start(예정 세션 불러오기 / 빈 세션 시작) → record(종목 박스, 세트 입력·완료·휴식 타이머·RPE·세트 리뷰·빠른 세트 추가·종목 완료) → finish(통계·시작/종료/총시간·세션 리뷰·종목별 요약). 운동 전/후 컨디션 입력은 기능 폐기로 제거(2026-08-24).
finish 즉시 저장 (이슈 #1017, 2026-08-31 오너 방식 A) — 라이브 신규 기록(!backfill && !edit)에만:
- record → finish 전이는 확인 팝업 1회를 거친다: 전 종목 완료면 "운동을 종료할까요?" [운동 종료(
workout.finishStep.confirm)/조금 더 운동하기], 미완료 종목이 있으면 기존 "아직 완료하지 않은 종목이 있어요" 팝업이 확인을 겸한다(중복 팝업 없음). - finish 진입 순간 종료 시간이 확정되고(
finish.endTime= 진입 시각, 재개 복원이면 기존 값)onAutoSaveSession이 1회 호출된다. 성공하면 초안 스냅샷 발행이 멈추고(부활 방지) 하단 버튼이 "메인으로"가 된다 — 메모/시간이 자동 저장값과 달라졌을 때만onSaveSession(edit 모드: editSessionId=자동 저장 id, exercisesDirty=false)으로 수정 저장 1회, 아니면onExit. - (2026-09-14) 종료 확인 카드의 "저장이 완료되었습니다" 단계 폐지 —
endAsk="saved"는 화면이 그리지 않고 즉시 닫는다(저장 중 카드 → 완료 화면). 저장 완료 알림은 컨테이너 토스트 1회(저장하였습니다, kindsave)가 유일한 표면이다(완료 피드백 공통 규칙: 질문 = 모달, 결과 = 토스트 · 토스트 폭 240px, 확인 모달 폭 320px, 반경 22px·배지 52px·제목 17px는 동일). 자동 저장 성공(onAutoSaveSession)에 한 번 알리고, 변경 없는 최종 나가기에는 같은 저장 알림을 다시 내지 않는다. 자동 저장 실패 뒤 수동 저장에 성공했거나 저장 이후 내용을 다시 수정해 저장한 경우에는 해당 쓰기 결과를 한 번 알린다. 저장 중·실패 상태를 성공으로 표시하지 않는다. - 완료 화면 상단 뒤로가기(
finishBackButton)는 record로 복귀한다(2026-09-08 디자인, #1166 Phase 6 저장 계약 유지). 즉시 저장 성공 뒤 복귀는 저장된 기록의editSessionId·개정번호와exercisesDirty=true를 담은 초안으로 다시 발행하고 재저장은 같은 기록을 수정한다. 수정 모드에서는 비노출, 저장 중에는 잠긴다. - 삭제 링크는 "기록 삭제"로 바뀌고
onDeleteSavedSession({id})호출. 자동 저장 실패 시엔 종전 그대로 "저장하지 않고 삭제"(onDiscard)와 수동 저장 버튼(오프라인 대기열 포함)으로 폴백하고 배너로 안내한다. - backfill(지난 기록 작성)·edit(기존 기록 수정)·plan은 종전 그대로(버튼 저장 / 수정 완료·취소).
mode="plan"이면 record 화면을 재사용하되 완료 체크·휴식 등 기록 전용 요소는 숨기고 하단이 "계획 저장"(→ onSavePlan).
UI-Local State (Claude 소유)
- 현재 step, 활성 종목, 세트 입력/완료, 휴식 타이머(틱·기록·자유휴식), RPE·세트 리뷰, 빠른 세트 추가(드래그 삽입), 종목 완료/되돌리기, 세션 이름 인라인 편집
- 다이얼로그: 종목 종료, 미완료 종목 있는데 세션 종료, 저장 안 함/삭제 확인, 휴식 수정 팝업, 빈 세션 이름 입력
- 종목 선택 시트(검색·장비/부위 필터·커스텀 추가)
- 토스트(저장/삭제), busy(pending)
Persistence (Codex 소유) — UI import 금지
- Supabase /
services/barbelicApi/ 영속 저장 - 시작 시
initialDraft/plansForDate구성, 종료 시onSaveSession/onSavePlan결과 영속화, 통계/볼륨/PR 판정은 저장 후 도메인에서 계산 - "이어하기(resume)"는 컨테이너가
initialSnapshot(우선) 또는initialDraft로 in-progress 세션을 주입하는 형태
복귀 안전 (resume) — 중요
진행 중 드래프트는 UI-local이지만, 탭 이탈·새로고침으로 사라지면 안 된다. 그래서:
onDraftSnapshot(snapshot)를 step 이동·종목/세트 변경 후 10~20초 debounce + 탭 이탈 시 호출 → 컨테이너가 Supabase/임시저장에 보관.- 재진입 시 컨테이너는
initialSnapshot우선 주입(step·활성 종목/세트·휴식 타이머까지 복원).initialSnapshot이 없고 draft만 있을 때만initialDraft사용. 홈의 "진행중인 세션으로 돌아가기"도 이 경로. - (대안 아님) WorkoutFlow가 마운트 유지된다고 가정하지 않는다 — 스냅샷으로 안전하게.
data-lg-* 마커 (designContract.js 등록명)
view: workout.flow, workout.start(sessionStartStep), workout.record(sessionRecordStep), workout.finish(sessionFinishStep), workout.picker(exercisePickerView) action: workout.start/resume/usePlan/skipPlan/finishStep/finishStep.confirm/saveSession/discardSession(+.confirm)/finish.home, workout.exercise.add/start/finish/finishEarly/note.save, workout.set.add/complete/edit/editDone/remove/perf.save/quickAdd(+.confirm/.cancel)/rest.record/rest.save/rest.free/rest.edit/rest.close, workout.plan.saveBar/rename, field: workout.session.date/startTime/preNote/note, workout.set.type/load/reps/perceivedRpe/note/restSeconds, workout.exercise.feel/note, workout.picker.search/equip/part (workout.session.condition/postCondition은 기능 폐기로 등록 해제, 2026-08-24) → 새 마커 필요 시 PR 설명에 적고 Codex가 등록.
범위
이번 슬라이스 = 운동 플로우(시작/기록/종료) + 플랜 편집 모드 + 종목 선택 시트. 종료 저장 payload는 세션 화면 계약의 SessionFull과 동일 형태로 맞춘다(세션 상세에서 그대로 조회).
운동 디테일 + 복합 종목 (D1 2차, 2026-07-24 — 데스크톱 계약의 모바일 이식)
exercises[].details: string[]— 수식 태그(최대 3). 에딧 중 = 칩 스트립 + "+ 디테일"(프리셋 드로어: 종목별/공통 + 직접 입력), 확정/조회 표기 = 평문 접두. 통계·PR·볼륨 귀속은 기존 exerciseId 그대로. 저장 payload(buildSession/buildPlan)에 왕복 보존.exercises[].composite?: [{ exerciseId, name, details }]— 복합 종목(피커 "여러 종목 묶어서 하기" → 빌더). 대표 exerciseId = 첫 동작, 이름 = 자동(디테일 접두 + " + " 연결). 세트sets[].parts?: [{ reps?, load?, durationSeconds?, distanceMeters?, calories? }](동작별 값 묶음 — 이슈 #1188 단일 형식, reps = 횟수 합), 표기 "load × (a+b)".- hook = 데스크톱 등록 동명 재사용: exerciseDetails / exerciseDetailAdd / compositeBuilderOpen / compositeBuilder + restDock(신규 — Codex 등록 필요). 신규 action 0.
- Codex 후속: ① details·composite·parts 영속 왕복 ② composite fan-out(세트×동작 → 각 exerciseId 회수·볼륨·PR 귀속, 복합 대표 세트는 e1RM 제외 — 데스크톱과 동일 규칙) ③ 프리셋을 관리자 디테일 카탈로그에서 공급.
자유 기록 (kind:"note", 2026-07-30 — 데스크톱 DkpNoteCard 계약의 모바일 이식)
- 피커 푸터 "자유롭게 종목 기록하기"(hook
freeNoteAdd) → 즉시 드로워 닫고 note 카드 추가.exercises[].kind === "note": 제목=name(≤40,workout.note.title) + 본문=note(≤500,workout.note.body)만 — 세트/디테일/단위/종목옵션 UI 없음, sets 항상 []. - 저장 활성 = 제목·본문 둘 다 공백 아님. 완료 뷰 = 페이퍼 박스(.wfx-note-view). 완료 말풍선(총 Nkg·체감) 미노출.
- 완료 시점(2026-08-23 수정): live(운동 진행)에서 저장(
workout.set.editDone)은 내용 확정만(noteSaved) — done은 일반 종목과 같은 하단 도크 "눌러서 종목 완료하기"(workout.exercise.finish, 캡션 "자유 기록 진행중", 되돌기 버튼 없음)에서만 찍힌다. backfill·plan은 기존대로 저장 즉시 done. '운동 종료' 미완료 확정 시 본문 있는 note는 보존·완료 처리(빈 본문만 제외). 리뷰 도크 "다음 종목"은 미완료 note도 후보. - 세트 수정 모달(
setPerfComposer, 2026-08-23 수정): "세트 내용" 입력은 종목fields를 따른다 — 무게×횟수 프로필만 kg×회(단위 리셋 포함), 그 외(러닝 = 거리 m × 시간 초 등)는 편집 행과 같은 원자 입력(workout.set.distance/duration/reps). 실패 기록·RM 시도 UI는 reps 항목이 있을 때만. 완료 말풍선도 무게×횟수 외 프로필은 kg 합계 대신 항목 합계. - 리뷰는 별개 필드
exercises[].review(본문 note와 분리). onSaveSession 맵: note 카드 ={ kind:"note", note(본문), review(리뷰) }, 일반 종목 = 기존대로 review=e.note. - Codex 후속: 모바일 세션 저장 경로 kind:"note" 왕복(exerciseId 없음 — 통계/PR 미귀속, 세션 상세·일지 미리보기는 데스크톱 기구현) + note 카드 review 필드 영속.
2026-07-31 · 4표면 통일 + 작성/수정 모드 정리
- 모드 매트릭스: live(운동 진행) · backfill(지난 기록 작성) · plan(계획 작성/수정) · backfill+세션 프리필(지난 세션 수정, SessionScreen onEditRecord 위임). WorkoutRecord의 live 전용 요소 분기는
live플래그 단일 축. - 작성/수정(backfill·plan)엔 progress 개념 없음(07-31 지시): PROGRESS 게이지·세트 완료 체크·"빠른 세트 추가"·"종목 종료하기"·진행중/안했음 태그 = live 전용. 난이도·휴식·메모 표시는 done 무관.
- backfill 저장 시 done 강제 true(buildSession: 세트·종목) — 작성 모드에 완료 개념이 없으므로 컨테이너는 backfill 결과의 done을 신뢰하면 됨.
- finish 초기값: backfill + initialDraft(기존 세션)일 때 endTime/postCondition/note 프리필 (postCondition은 UI 없이 값 보존용 통과만, 2026-08-24).
yearStats?: { ordinal: number; days: number }(optional, 2026-08-07 신설): finish 리뷰 카드 디폴트 말풍선 "YYYY-MM-DD / 올해 n번째 오운완 (nnn/365)" — ordinal = 그해 세션 순번, days = 그해 운동일 수(중복 날짜 제외). 미제공 시 날짜 줄만 렌더. 앱쉘이 세션 목록에서 계산해 내려준다 (Codex 후속).
2026-08-27 · 도크 기하 계약 C′ (이슈 #855, 2026-09-08 디자인 갱신 — 전 상태 총높이 항등)
- 라이브 하단 도크 5상태(종목 시작 대기
.wf2-act.start· 세트 진행중.wf2-act· 세트 휴식.wf2-rest· 종목 완료 대기.wf2-act.finish· 종목 완료 확정.wf2-act.review)는.wf2-stage래퍼(height =--dock-stage-h104px 고정, min-height 금지) 안에 콘텐츠를 상단부터 쌓는다 — 총높이 = 16 + 104 +--dock-inset항등. 인셋 = 8px + safe-bottom, 명목 도크 높이 = 128px. 하단 텍스트 버튼을 캡션 옆 칩으로 옮겨 전 상태에서 32px 존을 함께 제거했다. 정본 = workout.css "도크 기하 계약 C′" 주석 블록. - 도크의 렌더·상태 소유는 종전대로 WorkoutRecord(UI-local) — 셸은 기하(스테이지·인셋 토큰)와 크롬 채널(
data-bottom-chrome="dock"= 플로우 프레임 정적 게시)만 소유한다. 이 경계는 §핵심 경계와 렌더 입자성 불변식(세트 입력 루트 커밋 0)을 지키기 위한 것. - 도크 마크업 수정 시: diff-card·fail-card(absolute)는 스테이지 밖/도크 루트 기준 유지, 되돌기는 캡션 행의 흐름에 배치한다. 콘텐츠 합이 스테이지를 넘으면 기하 게이트(
e2e/dock-geometry.spec.mjs오버플로 단언) 실패. 스테이지 증감은 전 상태 동시에만(토큰 1곳).
2026-09-08 · 운동 입력 디자인 통합
- 종목 시작 대기: live의 미완료 일반 종목은
exerciseStartDock에서workout.exercise.start를 눌러 진행 표시를 시작한다. 시작 여부는 UI-local이며 기록을 쓰지 않는다. 완료 세트가 있는 종목은 재진입 때 바로 진행 상태로 복원하고, 자유 기록·backfill·plan에는 이 관문이 없다. 완료 도크의 "다음 종목 시작하기"는 다음 미완료 종목을 선택하면서 같은 시작 상태를 켠다. 다음 종목이 없으면 기존onPickExercise로 종목을 추가한다. - 휴식 기록: 기본은 생략(
restSkip=true). 사용자가 "휴식 기록"을 켜면 기존 타이머·기록 경로를 사용한다. 생략은 기존freeRest=true의미를 유지하고 기존 초안의rest·freeRest·타이머 값을 초기화하지 않는다. 휴식 기록/충분히 쉬기와 세트 실패 버튼은 기존 콜백을 그대로 호출한다. - 뒤로가기: live record 상단 뒤로가기는
publishedDraftSnapshot()을 발행한 뒤 기존onExit로 홈에 복귀하며 세션을 유지한다. 시작 화면은onExit, backfill은 기존 빈 초안 폐기/취소 확인 경로를 사용한다. 진입·복귀 푸시와 퇴장 애니메이션은 reduced-motion에서 즉시 전환한다. - RPE 입력 카드: 완료 카드의 직접 숫자 입력은 디자인 게이트로 숨기고 단축 버튼을 유지한다. 기존
perceivedRpe를 지우거나 반올림하지 않으며 1.0~10.0 범위, 소수 값 왕복, 수정 컴포저, 저장 payload는 그대로다.
2026-08-24 · 빈 세션 종료 (기록 0개 = 완료 화면·저장 없이 종료)
- 종료 버튼("운동 종료"/"작성 완료"·마지막 종목 "운동 종료하기")은
attemptFinish게이트를 지난다: 종목 0개 = 확인 없이 즉시onDiscard({ cause })(완료/요약 화면·저장 없음), live에서 종목은 있는데 기록 0개(완료 세트·완료 플래그·작성된 자유 기록 전무, 판정 =mobileWorkoutExerciseRecorded) = 확인 카드 "기록한 세트가 없어요" 1회 뒤 폐기. backfill은 직렬화가 done을 강제하므로 종목이 있으면 기존 경로 그대로. - cause: live =
"empty-live-finish"→ 토스트 "운동 진행을 종료합니다" · backfill ="empty-record-finish"→ "기록 작성을 취소했습니다"(작성 취소 링크의 종목 0개 경로 포함). 지난 세션 수정 모드는 "수정을 취소하였습니다" 우선(빈 저장으로 기존 세션을 비우지 않는다). 컨테이너 커맨드(activeWorkoutCommands.discard)는 아는 cause만 통과. - plan 모드: 저장 버튼은 종목 0개에서 기존대로 disabled, 취소·"저장하지 않고 나가기"는 컨테이너
exitPlan({ notify: true })→ 토스트 "계획 작성을 취소했습니다"(탭 전환 이탈은 무음). 신규 데이터 마커 0(빈 확인 카드 확정 = 기등록workout.discardSession.confirm).
2026-09-08 · 공통 편집기 관문 (v0.18.0 A12, 이슈 #1339)
onSaveSession·onAutoSaveSession·onSavePlan이 받는 payload(buildSession/buildPlan 결과)는 바인딩(WorkoutFlowBinding)에서 플랫폼 공통 headless editor 의 판정(workout-editor.md §9)을 먼저 지난다 — 입력 중(12.)·틀린 값이 남은 세트가 있으면LG_EDITOR_INPUT_INCOMPLETE오류로 그 자리에서 돌아오고(초안은 그대로), 통과하면 payload 그대로 컨트롤러로. 진행 중 운동은 완료 세트만, 작성·수정·계획은 전 세트가 판정 대상이다. 화면(UI) 코드·마커·CSS 는 바뀌지 않았다.- 화면 컴포넌트를 편집기 상태·view model 소비자로 바꾸는 것은 U02(Phase 3) 몫이다.
2026-09-14 · 완료 화면 리뷰 입력 — 문구 + 사진 첨부 + 사진 카드 (디자인 델타, base main b622cc808b47)
- 문구: 세션 리뷰 placeholder "운동후 느낀 점, 고칠 점 (선택)" → "운동 후 느낀 점을 기록해주세요"(
sessionNote마커·note저장 경로 불변). - 신규 props(전부 선택,
UiWorkoutFlow→UiWorkoutFinish통과). 타입 정본 =mobile/lib/PhotoCard.tsx(SessionPhoto/PhotoCardOverlay/PhotoCardStats):
| prop | 형태 | 설명 |
|---|---|---|
photos? | ReadonlyArray<SessionPhoto> | null — { id, url, overlay?: { theme: "white" | "black", stats } } | 이 세션에 첨부된 사진 목록 — 컨테이너 소유(미리보기 URL은 object URL·서명 URL 무엇이든, overlay는 첨부 시점에 받은 값 그대로). 비어 있으면 스트립 미노출 |
onAddPhoto?(file, overlay) | (file: File, overlay: PhotoCardOverlay) => void | 입력창 안쪽 좌하단 카메라 버튼(.fin-photo-add, <label> + <input type="file" accept="image/*" hidden> 1장) → 사진 확인 페이지(아래) → "이 사진으로 첨부" 시 1회. 없으면 버튼 미렌더(프로필 카메라 배지와 같은 규칙) |
onRemovePhoto?(id) | (id: string) => void | 썸네일 우상단 × → 제거 의도. 없으면 × 미렌더 |
- 사진 카드(
UiPhotoCard,mobile/lib/PhotoCard.tsx, CSSbase/primitives.css .pc-*) = 사진(4:5,--r-lg) 위 오버레이, 하단 2블록(2026-09-14 08:30Z 확정): ① 종목 요약(우측 정렬 블록, 카드 폭 54% — 좌측 사진 비움) — 행 =종목명 볼륨kg(둘 다 우측에 붙음, 한 줄) + 아래 얇은 막대(0.8cqw, 채운 부분만 — 트랙 없음, 우측 끝 기준, 폭 = 최대 볼륨 대비). 세트 수 없음. 볼륨 내림차순 최대 3행 + 나머지 "그 외"(볼륨 합산) 1행 ② 맨 아래 한 줄 = 좌 총 kg(13.5cqw, 카드 최대 숫자) + "KG" · 우하단 운동 시간n+min(4.6cqw/2.8cqw, 작게) — 같은 기준선, 6자리 kg에도 한 줄. 상단 행 = 좌 워드마크(BRAND_COPY.wordmark) · 우 운동 날짜YYYY.MM.DD(stats.dateISO, 09:40Z 지시). 글자색 =overlay.theme흰/검(하단 45% 스크림 = 반대색 그라디언트 38%, 아주 옅은 반대색 그림자). 모든 크기는 카드 폭 비례(cqw) — 피드·확인 페이지·이미지 베이킹이 같은 카드.stats={ date?(ISO), volumeKg(1kg 반올림), sets(메인 — 카드 미표시, 보존), duration(분 숫자 문자열 "112" — 카드가 min 라벨을 붙인다), exercises[{ name, sets(메인, 미표시), volumeKg(총 kg와 같은 공식·전 세트) }] }— 완료 화면이 이미 그리는 값의 재포장이며 첨부 시점에 확정된다(카드는 순위·그 외 합산만 한다, 재계산 없음). 시안 페이지(로컬)_preview/_design-photo-card.html. - 사진 확인 페이지(
UiPhotoComposer,mobile/lib/PhotoComposer.tsx, CSSworkout.css .pc-page) = 순백 전체 화면(UiModal포털.confirm-scrim.pc-page, safe-top 확보): 상단 × + "사진 확인" · 카드 미리보기 · 글자색 라디오 [흰색 | 검정](기본 흰색, 카드 즉시 반영) · 하단.btn.btn-primary.block"이 사진으로 첨부". 열림 상태·File은 완료 화면 로컬(pendingPhoto, 모달 스텝 — 탭 스택 항목 아님). 배경 탭으로 닫히지 않는다. - 상태 생김새(완료 화면): (a) 기본 = 입력창 116px(하단 48px = 버튼 행), 좌하 카메라(회색 33px 사각) · 우하 ↑ 전송(페리윙클) — 카메라 미배선이면 종전 96px 그대로 (b) 첨부 1장+ = 디폴트 말풍선 아래·입력창 위에 아바타 + 72px 썸네일 행(원본 사진, 우측 정렬, 채팅 '내 말' 문법) (c) × 탭 = 해당 썸네일 제거.
- 전환 규칙: 카메라 탭 → OS 파일 선택 → 확정 시 확인 페이지 열림(취소 = 무동작) → [글자색 고르기] → "이 사진으로 첨부" =
onAddPhoto(file, overlay)1회 + 페이지 닫기 / × = 닫기만(호출 0) → 컨테이너가photos갱신 → 썸네일 스트립 렌더 · × 탭 →onRemovePhoto(id). 화면은 사진을 보관하지 않는다 — 저장 시 첨부 사진·오버레이의 정본은photos. 저장된 사진은 피드/홈 포스트에 같은 카드로 노출(feed-props.md 2026-09-14 절). - Codex 후속: ①
photos/onAddPhoto/onRemovePhoto컨테이너 배선(업로드·세션 귀속·overlay영속 —MobileFinishInput/MobileSessionEdit확장 또는 별도 포트, 계약 소유 Codex) ② 마커 등록 요청workout.session.photoFile(field, 카메라 input) ·workout.session.photo.confirm(action, 첨부 확인) ·workout.session.photo.remove(action, ×) — 등록 전까지 미부착 ③WorkoutFlowDataProps·WorkoutFlow.fixture.ts에photos(선택) ④ 첨부 장수·용량 상한은 컨테이너 정책(버튼은 상한을 모른다 — 잠글 땐onAddPhoto미전달) ⑤ 공유·이미지 저장용 베이킹(사진 + overlay → 한 장)은 네이티브/서버 — 레이아웃 정본은.pc-*CSS(cqw 비례).