Skip to content

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 상태다. 컨테이너는 시작 데이터만 주입하고 완성된 결과만 콜백으로 회신받는다. 세트 하나하나를 컨테이너로 올리지 않는다.

txt
src/react/ui/screens/WorkoutFlow.jsx     (export: window.UiWorkoutFlow)
src/react/ui/fixtures/workoutFixture.js   (preview data = 계약 형태)

Props

jsx
<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

ts
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회(저장하였습니다, kind save)가 유일한 표면이다(완료 피드백 공통 규칙: 질문 = 모달, 결과 = 토스트 · 토스트 폭 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-h 104px 고정, 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(전부 선택, UiWorkoutFlowUiWorkoutFinish 통과). 타입 정본 = 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, CSS base/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.date ISO, 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, CSS workout.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.tsphotos(선택) ④ 첨부 장수·용량 상한은 컨테이너 정책(버튼은 상한을 모른다 — 잠글 땐 onAddPhoto 미전달) ⑤ 공유·이미지 저장용 베이킹(사진 + overlay → 한 장)은 네이티브/서버 — 레이아웃 정본은 .pc-* CSS(cqw 비례).