Skip to content

데스크톱 화면 props 계약 (src/react/ui/desktop/**)

작성: 2026-06-10 · C안(멀티패널 워크스페이스) 1차. 모든 컴포넌트는 순수 프레젠테이션 (props-in / 콜백-out), Supabase·services·영속 저장 import 없음. 콜백은 Promise 반환 가능. 공통 데이터 형태(sessionLite/session(full)/exerciseLite/exerciseDetail/profile)는 모바일 계약(session-screen-props.md, records-props.md, profile-screen-props.md)과 동일. (profile 계약은 2026-08-19 실코드 기준 재작성본 — 공통 profile 형태 정의 포함.)

로드 순서: DesktopWidgets.jsx → 각 화면 (화면들이 window.UiDesktopWidgets를 참조). 스타일: desktop/styles/desktop.css 단독 로드(모바일 styles.css 불필요). 루트 엘리먼트에 .dk-app 필요. 변형: data-accent="ocean|teal"(생략=sky), data-dk-density="comfortable"(생략=compact).

DesktopSidebar — 로그아웃 (#1530, v0.17.10)

  • 왼쪽 고정 메뉴 하단에 로그아웃 버튼을 둔다. 내 정보 화면의 기존 로그아웃도 유지한다.
  • onLogout: () => unknown을 필수로 전달한다. GymDesktopApp은 내 정보와 같은 공통 signOut을 연결하며, 버튼은 인증 서비스·저장소에 직접 접근하지 않는다.
  • 버튼은 type="button", 기존 action profile.logout을 사용한다. 마우스·Enter·Space로 실행할 수 있고 탭 이동과 별개의 실행 버튼이다.
  • 현재 기기 로그아웃·로그인 화면 복귀·계정별 정리는 기존 컨트롤러가 맡는다. 모든 기기 로그아웃이나 계정 삭제로 연결하지 않는다.

UiDesktopHomeScreen (홈 v3 · 세션 피드 + 통계 컬럼 — 2026-07-08)

  • 레이아웃: 카드 한 장, 인카드 헤더 없음 — │ 좌 프로필/통계 컬럼(프로필 · 벤치마크 기록 · 연간 잔디 · 들어올린 무게 · 주간 계획) / 우 세션 피드 컬럼(쓰레드 포스트), 넓은 본문에서는 비율 1:1, 좁으면 한 열 · document 스크롤 · 사이 hairline. (내비 라벨 "프로필")
  • 프로필(통계 컬럼 최상단, hook homeProfile): profileName profilePhoto? profileShort? + monthStats.volume("이번 달 N kg 들어올림") + 벤치마크 기록 bestRecords [{ id, name, nameEn?, glyph?, value, unit, grade?, topPct? }] (최대 8개, 셀 클릭 = pr.open/onOpenExercise). bestRecords는 유일한 기록 입력이며 즐겨찾기 배열로 대체하지 않는다.
  • feed [{ kind:'session', session(lite), title, when(YYYY-MM-DD · 시각 · 소요), duration?, review?, kpi:{ volume, sets, exercises, reps }, mains?:[{ name, nameEn?, sets:[{ load, reps }] }], author?:{ id, name, handle?, photo?, short? } }] — author(2026-08-23 팔로잉 포스트)가 있으면 팔로우한 친구의 카드: 포스트 헤더 아바타/이름을 author로 그린다(없으면 내 프로필) — 세션 포스트만 표시(session 외 kind 무시). 헤더 = 닉네임(profileName) + 완료필 + when, 리뷰가 본문, 기록은 첨부 카드(헤더=세션명(title), 일지 '이 날의 메인 세트' 문법에서 톱세트 accent 제외, max-width 480px) · mains 없으면 푸터에 세션명+KPI. feed가 없거나 비어 있으면 빈 피드를 표시하며 activity 등 다른 배열로 합성하지 않는다.
  • 좌 통계: yearRecap(1월 1일부터 최대 371칸 + KPI: 세션/운동한 날/무게/mainReps?/PR — yearRecapsByYear? {"2025": recap 동형} 공급 시 헤더 ◀▶ 연도 스테퍼) · youLifted(yearGoal 포함) · weekPlan(항상 7일, 날짜별 sessions[], 완료 volume/계획 estVolume/완료 top). 잔디 날짜 상세에는 sessionsByDatefullById를 사용한다.
  • 현재 계약에서 제거됨: activity topExercises trainingLoad todaySession bigThree volumeTrend partDistribution favoritePRs recentPRs prCards prCardExercises todayPlan recentSessions todayLabel homeMonth homePeriods planProgress. UI와 mapper는 이 값을 수용하거나 폴백으로 읽지 않는다.
  • 콜백: onOpenSession(lite) onOpenDay(dateIso) onRequestDay(dateIso) onDayModalChange(dateIso|null) onOpenExercise(id) onCreatePlan(dateIso?) onAddPastRecord() onGoVolume(period?).
  • 마커: view dashboard.home, hook dashboardView/homeActivity(피드)/homeProfile/homeBestRecords/homeYearRecap/homeYouLifted/homeWeekPlan · action session.open pr.open.
  • 빈 피드(세션 포스트 0 && 초기 로딩 아님, 2026-07-18): 피드 컬럼 정중앙에 웰컴 히어로 카드 1장(마스코트 일러스트 + "○○님, 환영해요!" + CTA onAddPastRecord/onCreatePlan). hook homeEmptyFeed. 데이터 0 참조 fixture: desktop/fixtures/desktopFreshFixture.ts.
  • 데모 데이터: desktop/fixtures/desktopHomeFixture.ts(dkHomeFixturefeed 포함) — 컨테이너 집계 형태 참조용

UiDesktopSearchScreen (세션 검색 — 2026-07-08)

  • 레이아웃: 카드 한 장(max-width 1120 중앙) — 헤더(검색 인풋 + "PR 세션만" 토글 + 결과 카운트, hairline) 아래 홈 피드와 동일한 포스트 문법(.dk-h3-post 재사용)의 결과 피드.
  • sessions [{ session(lite), title, when(YYYY-MM-DD · 시각 · 소요), review?, kpi:{ volume, sets, exercises }, mains?:[{ name, exerciseId?, pr?, sets:[{ load, reps }] }] }] 최신순 — get_session_search가 반환한 최근 완료 세션 최대 120건. 달력 월 캐시를 재사용하지 않는다.
  • 로딩 호환 props: loading? initialLoading? loadingLabel? — 컨테이너가 검색 인덱스 적재 상태를 전달한다. 현재 화면에서 카드 로딩 표현을 연결하는 작업은 Claude Design 소유다.
  • initialQuery? initialPrOnly? — 딥링크 초기 필터(PR 상세 "자세히 보기" → 종목명/PR만). 컨테이너는 값 변경 시 key로 재마운트.
  • 필터링(클라이언트): 쿼리 = 세션 제목 or 종목명 부분일치(히트 종목 행 accent 강조), "PR 세션만" = mains.pr 포함 세션만. PR 종목 행엔 PR 필 표시(홈 피드와 달리 검색 맥락에서는 표시).
  • 콜백: onOpenSession(lite) · 헤더 닉네임/아바타: profileName profilePhoto? profileShort?
  • 마커: hook sessionSearchView(식별용 신규) · action session.open 재사용 · view는 미부착 — dashboard.search 등록 후 부착(Codex 후속)
  • 데모 데이터: desktop/fixtures/desktopSearchFixture.ts(dkSearchSessions)

UiDesktopSessionScreen (운동일지 v2 · 저널 워크벤치 — 2026-07-29)

  • 레이아웃: 2컬럼 — 좌 단일 월 스텝퍼 달력(7일+주 합계 열, 날짜 셀 안 세션 엔트리, 이전/다음 월 버튼, 오늘 이동, UUID 종목 필터) / 우 하루 패널(날짜+하루 통계 → 시간순 세션 레일 → 그날의 메인 세트). 주/월 선택 시 우측은 DkRangeView 요약으로 바뀐다. 세션 상세와 계획/백필 작성·수정은 각각 중앙 모달로 열린다.
  • today ISO · selectedDate ISO · sessionsByDate { iso: [sessionLite] } · selectedSession session(full)|null
  • fullById { id: session(full) } — 달력 셀 볼륨·주/월 합계·하루 통계·레일 시간(startTime) 계산에 사용. 상세 미로딩 완료 세션도 volume/setCount/exCount 요약값을 포함하므로, 달력은 exercises가 비어 있어도 명시적 volume을 먼저 표시해야 한다 (미주입 시 메타 폴백=lite.summary).
  • visibleMonth { year, month } — 표시 월 초기값·외부 날짜 이동 동기화. 평범한 월 스텝은 선택 날짜를 바꾸지 않고 onEnsureMonth({year,month})로 표시 월과 인접 월 데이터를 요청한다.
  • highlightExerciseId UUID|null — PR 로그 점프 시 조회 모달의 해당 종목을 강조한다. 이름·slug·뷰 행 ID는 identity 폴백으로 쓰지 않는다.
  • 달력 필터: 검색 결과에서 UUID 종목 하나를 선택하면 그 종목을 포함한 세션만 남긴다. 세션 ID는 비 UUID를 포함할 수 있는 opaque 값으로 취급한다.
  • exerciseCatalog favorites oneRms bodyWeight defaultPlanName defaultSessionName — 중앙 계획/기록 컴포저 입력. bodyWeight는 새 draft용 최신 체중이며, 기존 기록 통계는 session에 저장된 체중 스냅샷을 사용한다.
  • exerciseSearchSignals (선택, 이슈 #1101) — 종목 피커 결과 순서 신호(ExerciseSearchSignals | null: 내 종목별 마지막 수행일·종목별 사용자 수). 컴포저의 DkpPicker까지 그대로 전달되며, null이면 인기 등급만으로 정렬한다.
  • onUpdateSession(sessionId, patch) / onDeleteSession(session) — 기록 수정 모달 저장과 원본 세션 삭제. 삭제는 session ID만이 아니라 원본 세션 객체를 전달한다.
  • 콜백: onSelectDate(iso) onEnsureMonth(month) onGoToday() onOpenSession(lite)(행 클릭, 같은 행 재클릭=닫기) onCloseSession() onSavePlan(date,draft) onSaveBackfill(date,session) onAddCustomExercise.
  • 기록 수정은 구 상세 내부 인라인 편집기가 아니라 중앙 컴포저를 사용한다. 읽기 전용 상세는 조회·수정 진입·삭제 확인만 소유한다.
  • 마커: view dashboard.calendar/dashboard.session.detail, hook calendarScroll/journalDayPanel/journalTimeRail(신규 2개는 식별용)/sessionDetailView/sessionDeleteDialog, action 재사용 calendar.today/calendar.month.select/calendar.week.select/calendar.plan.add|backfill.add|plan.editToggle/session.open/ session.detail.edit|delete/session.delete.confirm|cancel, field workout.session.note(중앙 에디터 textarea). condition/postCondition 칩 UI는 기능 폐기로 제거(2026-08-24) — draft 필드는 기존 세션 값 보존용으로만 통과 — 신규 action 없음
  • 삭제된 구성(구 C안): 미니 달력 · 세션 리스트 pane · 인스펙터 · 상세 내부 인라인 편집기 · 종목 요약 패널 — 활성 기능은 하루 패널·중앙 상세/컴포저 모달로 통합
  • 맨몸운동 체중 반영 (2026-07-19): 새 prop bodyWeight?(kg) — 내장 계획/기록 에디터(UiDesktopPlanEditor)로 전달. 세션(full)에 bodyWeight?(저장 당시 체중 스냅샷) + 종목에 bwFactor?가 있으면 모든 볼륨/강도/존/탑세트 집계가 유효 무게(load + bodyWeight × bwFactor) 기준으로 계산되고, 세트 표기는 "체중+10kg × 8"이 된다(툴팁 = 환산 kg). 맨몸 세트의 "1RM 대비"(등록 1RM)는 기준 상이로 미표시. 규칙 구현: ui/shared/bodyweightSemantics.ts.
    • 일 상세 탑세트 TOP 3 (2026-08-23, 오너 D1): 서버 get_calendar_day_summary.top_sets가 유효 무게(stats_effective_load_kg)로 순위를 매기고 bodyweight_factor를 실어 준다(유효 무게 0 제외, NULL 뒤로). 행 표기 = 유효 kg × 회, 맨몸 세트는 .ld .bw 태그 "체중"/"체중+Nkg"(topSetKg/topSetBwTag, ui/shared/bodyweightSemantics.ts), 캡션 "유효 무게 기준", KPI "최고 중량 세트"도 유효 kg. 모바일 DayDetailBody 동일 규칙. 구 서버 응답(키 없음)은 외부 kg로 폴백 — 배포 순서 무관.
  • 수행 디테일 표기 (2026-07-18 · 07-19 재적용): 종목에 details?: string[]가 있으면 조회 모달·일 메인세트 요약 종목명에 "디테일 종목명" 평문 접두(dksDtlTxt).

UiDesktopPrScreen

  • 주요 props: exercises favorites recentPRs selectedExercise cards cardExercises board와 필수 controlled search. 데스크톱 닫기 콜백 이름은 모바일의 onCloseExercise와 달리 onCloseDetail이다.
  • 히트맵 날짜 상세는 dayDetailsByDate dayDetailLoadingDates dayDetailErrorsByDateonRequestDayDetail(dateIso, exerciseId)의 controlled 계약만 사용한다. selectedExercise.dayDetails 같은 선적재 사본으로 폴백하지 않는다.
  • search = 컨테이너가 소유하는 검색 계약: { input, query, items, results, hasQuery, isPending, debounceMs, onInput(value,isComposing?), onCompositionStart(), onCompositionEnd(value), onCommit(value?), onClear() }. input은 인풋에 즉시 표시하는 값, query는 IME 조합 종료 후 debounce가 끝나 실제 결과에 적용된 값이다. results는 공백 차이와 검색 토큰 순서에 무관하게 매칭된 전체 종목 목록이다.
  • 콜백: onOpenExercise(id) onCloseDetail() onToggleFavorite(id) onReorderFavorites(orderedIds) onAddManualPr(input) onOpenSessionFromHistory(log) onOpenReport(id) onOpenDate(iso) onEnsureYear(year) onEnsureHistoryYear(year) onRequestDayDetail(iso,id).
  • 레이아웃: selectedExercise가 없으면 종합 기록·즐겨찾기 표와 controlled 검색 드로어를 포함한 보드, 있으면 해당 종목의 전폭 상세를 렌더하는 2-state 구조다. 데스크톱 콤보 상세와 인라인 퀵룩 경로는 사용하지 않는다.
  • 검색 상태·정규화·필터링은 컨트롤러 소유다. UI는 value={search.input}을 사용하고 onChange에서 search.onInput(value, nativeEvent.isComposing), composition start/end에서 대응 콜백, Enter에서 search.onCommit(), 지우기에서 search.onClear()를 호출한다. 결과 목록은 로컬 .includes() 대신 search.results를 렌더하며 search.isPending 동안 직전 결과를 유지한다. Claude Design은 이 어댑터만 화면에 연결하고 검색 로직을 UI에 복제하지 않는다. (field pr.search, action pr.search.clear)
  • 마커: view dashboard.pr/dashboard.pr.detail, hook mobilePrView/prDetailView/prChart/prVolChart, action pr.open/pr.detail.fav/pr.log.open
  • PR·탑세트 블록 행은 onOpenSessionFromHistory(log)로 세션 화면을 열고, 훈련 목록 행은 먼저 세트 상세 모달을 연다. 세션 로딩 key는 log.sessionId ?? log.id를 정확히 사용한다.

UiDesktopVolumeScreen (훈련 통계 · 종목별 볼륨)

  • periods { week|month|quarter|year: periodData } — periodData = { total(표시문자열), unit, rangeLabel, sets, sessions(카운트), days, trend:[{label,rangeLabel,iso,volume,durationSessionCounts,timeSlotCounts}], byExercise:[{id,name,nameEn,volume,sets}](volume 내림차순), sessionList:[{id,date,title,summary,sets,volume,condition,exerciseIds:[id]}] }. durationSessionCounts[<30,30..<60,60..<90,90..<120,>=120분] 5칸, timeSlotCounts는 06:00~24:00의 30분 단위 36칸인 서버 집계입니다. sessionList는 현재 offset 0에서 선택 종목의 수행 태그 분포를 집계할 때 사용하고, sessionsByDate/fullById는 일·주·월 drilldown의 세션·세트 요약을 구성한다. 데스크톱 경계에서 trend[].label은 프리뷰 fixture와 동일한 표시값(주 YYYY년 M월 N주차, 월 YYYY년 M월, 분기 YYYY년 N분기, 연 YYYY년)이며 iso는 정렬·조회용 canonical key로 유지한다.
  • favorites [exerciseId] — 종목 선택 드롭다운의 즐겨찾기 정렬
  • legacy preview의 startHoursByDate는 더 이상 운영 집계 원천으로 사용하지 않습니다. ④ 꾸준함 카드의 세션 시간 분포와 시간대 리듬은 선택한 trend[] 행의 위 두 exact vector만 렌더링합니다.
  • initialPeriod "week"|"month"|"quarter"|"year" (생략 또는 미지원 값=month)
  • 콜백: onOpenExercise(id) — 종목별 구성의 '기록 보기' → 나의 기록 상세 점프 · onOpenDate(iso) — 날짜 상세 모달에서 운동일지로 점프. goals/onOpenSession/onOpenRecords/sessionDetailLoadingIds/sessionDetailLoadingLabel은 컨테이너 호환용 예약 prop이며 현재 v4에서는 소비하지 않는다.
  • 레이아웃(v4): 상단 컨트롤 바(기간 세그 주간/월간/분기/연간 volume.period + 종목 선택 드롭다운 volExerciseSelect: 검색 volume.search+즐겨찾기 그룹+전체 종목, 맨 위 "전체 종목"=집계). 선택·드롭다운 open·검색어는 UI-local. 본문 카드는 헤드라인·요약·최고 강도 세트·막대 차트와 하단 3열(종목별 구성/강도 분포/훈련 밀도)로 구성한다. 막대 클릭은 주·월이면 날짜 상세, 분기·연이면 주간·월간 요약 모달을 연다. 이전 기간 값·종목별 추세는 trend 버킷 비율로 화면 합성한다.
  • 마커: view dashboard.volume; hook desktopVolumeView/volPeriodSeg/volExerciseSelect/volExselTabs/volGroupOption/volSaveImage/volHero/volBlock-*/volChartMode/volAllocation/volAllocLens/volIntensity/volRhythm/volRangeModal; action volume.period/volume.period.prev/volume.period.next/volume.exercise.select/volume.metric/pr.open.

UiDesktopProfileScreen

  • profile {name, short, meta, photo} · bodyMetrics {weight, muscleMass, bodyFat, updatedAt}; bodyFat은 체지방률이 아닌 체지방량 kg이다.
  • wodupImportMeta? inbodyImportMeta? 최근 인입 요약.
  • 계정 연결: socialIdentities? [{provider,label,connected,enabled}] · socialIdentitiesLoading? · socialIdentityError? · pendingSocialIdentityProvider? · onLinkSocialIdentity(provider). 이미 연결된 identity는 상태만 표시하며 해제 action은 제공하지 않는다.
  • 콜백: onUploadPhoto(file) onSaveBodyMetrics({weight,muscleMass,bodyFat}) onWodupImport(file) onInbodyImport(file) onExport() onImport(file) onLogout() onDeleteAccount?().
  • 성별(2026-09-02, 이슈 #1142): profile.sex("m" | "f" | "") + onSaveSex(sex). 아이디 카드 아래 "성별" 카드(hook profileSex, role=radiogroup): 남성/여성 dk-btn 두 개(현재 값 = primary), 현재 값과 다른 쪽을 클릭하면 즉시 저장. 미설정이면 힌트 "아직 없어요 — 등급이 남성 기준으로 매겨지고 있어요". 저장·토스트·등급 기준표 재적재는 컨테이너(profileController.saveProfileSex)가 맡는다.
  • 계정 삭제(2026-08-25, #663): onDeleteAccount 전달 시 "계정" 카드에 "계정 삭제" 행 추가. 클릭 → DkScrim 2단 확인(안내 목록·내보내기 힌트 → 확인 문구 계정 삭제 입력, 공유 카피 src/react/ui/shared/accountDeletionCopy.ts). 문구 일치 시에만 실행, ok:true면 앱이 로그아웃 상태로 전환, 실패면 인라인 안내 후 재시도.
  • 저장 계약: 사진/InBody 원본은 사용자 경로 private Storage, 수동 지표와 InBody 지표는 body_metrics에 source/source_ref 기준 upsert. 기존 body_fat_percent와 신규 body_fat_kg를 혼용하지 않는다.
  • 마커: view dashboard.profile, hook mobileProfileView/mobileSocialIdentityConnections/profileSex/profileBodyMetrics/profileWodupImport/profileInbodyImport/desktopSocialIdentityConnections/desktopProfileConsentSettings/importFile/accountDeleteButton/accountDeleteDialog, action profile.linkIdentity(data-lg-option-value=<provider>)/profile.export|import|logout/profile.deleteAccount(+.cancel/.continue/.confirm)/profile.consentToggle(data-lg-option-value=<consentType>)/profile.consentDocument, field profile.importFile/profile.deleteAccount.phrase
  • 개인정보·공개 설정(#1625): consentSettings? { items: ConsentPlanItem[](서버 요구 중 선택 동의만, accepted 포함), pendingType } · onSetConsent?(item, accepted)apply_user_consents_v1. 카드는 선택 동의가 하나 이상일 때만 보인다(서버 요구 전환 전에는 숨김). 끄면 서버가 다른 회원의 열람을 즉시 막고 개인 기록은 그대로 남는다. 필수 동의는 여기서 끌 수 없다(철회 = 계정 삭제와 같은 절차).

UiDesktopOnboarding (첫 사용자 온보딩 — 2026-07-17)

  • 흐름: view="login"(비로그인, Kakao·Google·Apple 버튼) → 컨테이너 OAuth → 신규 가입(프로필 미완성)이면 view="setup": 약관(필수 2건) → 신체 정보프로필 확인 → 1RM → 웰컴 → 기존 홈. 기존 회원은 setup 없이 바로 홈으로 간다.
  • 프로필 스텝에서 provider가 전달한 닉네임·사진을 프리필한다. 사진이 없거나 "기본 이미지로 시작"을 선택하면 성별별 기본 아바타 desktop/assets/avatar_default_m.png / avatar_default_f.png를 사용한다.
  • props: view "login"|"setup" · logoSrc? · providerProfile? { provider, label, nickname?, photo? } · loginProviders? [{ provider:"kakao"|"google"|"apple", label }] · oneRmExercises? [{ id, name, nameEn }] · welcomeAutoMs? (기본 0)
  • callbacks: onSocialLogin(provider) · onOpenTerms(kind) kind="terms"|"privacy" · onComplete(draft) — draft = { agreements:{terms,privacy}, consentDecisions?:[{ consentType, documentVersion, required, accepted }], nickname, useProviderPhoto, body:{ sex:"m"|"f", height, weight, muscleMass?, bodyFatPct? }, oneRms:[{ exerciseId, name, value, unit:"kg" }] }.
  • 가입 동의 구조(#1625, 2026-09-15): consentPlan?·onOpenDocument?(path)·mode?("onboarding"|"consentRefresh")·onSubmitConsents?(decisions)·allowDeferConsents?·onDeferConsents? — 의미는 모바일 온보딩 가입 동의 구조와 같다. 만 14세 확인은 동의 단계 첫 행이며 확인 전 "다음"이 잠긴다(#1637, 2026-09-15 — 로그인 화면의 버튼 잠금은 폐기). 로그인 버튼 아이콘은 각 서비스 정식 아이콘(shared/icons/socialProviderIcons). 동의문 전문은 새 탭(window.open)이며 noopener의 null 반환을 팝업 차단으로 보지 않는다. **bodyFatPct는 체지방률 %**이며 내 정보의 bodyMetrics.bodyFat(kg)과 혼용하지 않는다. · onEnterHome()
  • onOpenServiceInformation()(v0.17.1): 로그인·약관 단계에서 현재 운영자·문의처·공개 범위 안내(/legal/service-info.html)를 브라우저 새 탭으로 연다. 원래 가입 화면을 유지하며 noopener의 null 반환을 이유로 현재 탭까지 이동하지 않는다. 동의 상태나 제출 조건을 변경하지 않는다. 문구와 공개 안내의 의미는 모바일 온보딩 운영·공개 안내와 같다.
  • 마커: 식별용 hook desktopOnboarding / onboardingLogin / onboardingTerms / onboardingProfile / onboardingBody / onboardingOneRm / onboardingWelcome / onboardingAgeConfirm / onboardingConsentAll / onboardingConsentRow(+data-consent-type) · action(재동의 모드) onboarding.submitConsents / onboarding.deferConsents
  • 데모: _preview-local.jsx 사이드바 "온보딩 (데모)" 탭 — 좌하단 피커(로그인/가입 스텝/셸로 나가기), 웰컴 종료 시 홈 탭 진입

UiDesktopPlanEditor

  • date ISO · initialDraft { title, exercises:[{ id, name, details?:string[], sets:[{ id, type, load, loadPct?, loadLb?, reps }] }] } | null (수정 진입 시 전달 — 전 종목 확정 상태로 시작) · exerciseCatalog [{ id, name, nameEn, equip, part, details?:string[] }]. 카탈로그의 details는 해당 종목에서 선택 가능한 수행 디테일 후보이고, draft의 exercise.details는 사용자가 실제 선택한 값이다.
  • 복합 종목 (2026-07-17, B빌더+V1스킴칩): exercises[] 항목에 composite?: [{ exerciseId, name, details?:string[] }](순서=수행 순서, 2~5개)가 있으면 복합 종목. 이름은 "1 하이클린 + 1 저크" 자동 파생(구성 변경 시 재파생), 항목 exerciseId""(카탈로그 미등록 — 귀속은 composite[].exerciseId). sets는 reps(횟수 합) 옆에 parts?: [{ reps?, load?, … }](동작별 값 묶음 — 이슈 #1188 단일 형식). 1세트 = 시퀀스 1회 수행 · 세트당 무게 1개(kg/% 토글 미노출). 생성 = 피커 하단 "여러 종목 묶어서 하기"(보라) → 빌더 모달(hook compositeBuilderOpen/compositeBuilder), 구성 수정 = 카드 헤더 "종목 수정"(hook compositeEditOpen) → 동일 빌더 모달(초기값=현 구성, 확정 시 세트 parts를 슬롯 원본 인덱스(src)로 재매핑). 세트행 동작별 횟수 칩 팝오버(hook compositeMovementReps). 확정 뷰 = "60kg × (3+1)" + 동작별 1RM 대비 열(e1RM/강도 생략) + 통계 귀속 박스. 컨테이너(Codex): 저장 draft의 세트 × composite → 각 종목 (load, reps=parts[i].reps)로 fan-out해 통계·PR 후보 반영하고, 일반 종목과 복합 동작의 선택된 details를 세부 종목 행(session_exercise_part)의 details에 보존한다(이슈 #1215, 2026-09-04 — 그 전에는 composite_meta.exercise_details; 복합 소속은 이제 session_exercise 층이 관계형으로 든다). 신규 data-lg-action/field 없음(식별용 hook만). (workoutFixture.exerciseCatalog 형태) · defaultPlanName string
  • favorites [exerciseId|name] (선택, 2026-07-14) — picker 검색어 없을 때 프리셋 목록(없으면 전체 폴백)
  • backfill (선택 bool, 2026-07-15) — 완료한 운동 기록 모드: 세트 행에 휴식(초) 셀 추가(빈 값=자유 휴식 → set { rest:null, freeRest:true }, 입력 시 { rest:초, freeRest:false }), 확정 카드에 휴식 컬럼, 저장 버튼 "기록 저장", 기본 제목 폴백 "운동 기록"
  • oneRms { [exerciseId]: kg } (선택, 2026-07-15) — canonical exercise UUID로만 조회하며 확정 카드 우측 1RM 대비/e1RM/세트 강도 + kg↔% 입력 토글에 사용
  • 세트 필드: loadPct(% 모드 입력 원본) · loadLb(lb 모드 입력 원본), load는 통계용 kg 파생값. 단위 변경 또는 빈 입력 시 비활성 원본 필드를 null로 지워 stale 표시/저장을 막는다.
  • 콜백: onSave(draft) onCancel() onAddCustomExercise({name,nameEn,equip,part})
  • draft·확정상태(doneIds)·커스텀 종목은 UI-local. 저장 버튼은 종목 ≥1이면 활성(2026-07-15) — 미확정 종목 있으면 저장 대신 해당 종목으로 이동+경고(탭 빨강 필/선택 탭 빨간 테두리)
  • 마커: view dashboard.plan.editor/workout.picker, hook planEditorView/exercisePickerView/draftList/exerciseSearch/exerciseSearchResults/exerciseDetails/exerciseDetailAdd(equipTabs/partTabs는 07-14 필터 UI 제거로 미렌더), action workout.plan.cancel|rename|rename.save|saveBar/workout.plan.exercise.remove(.confirm)/workout.set.add|remove|edit|editDone/workout.picker.select|addCustom/workout.custom.save|cancel/workout.exercise.add, field workout.set.type|load|reps/workout.picker.search, slot workout.draft.list/workout.set.entryLabel
  • 드래그(세트 번호·종목 탭)는 포인터/DnD 핸들 — 액션 마커 아님 (모바일과 동일 규칙)
  • 컨테이너 라우팅: calendar.plan.add → 편집기 열기(initialDraft=null), planned 세션 상세의 calendar.plan.editToggle → 해당 draft로 편집기. onSavesession(status planned) 행 저장(save_session_v5) 후 캘린더 복귀
  • SessionScreen 경유 시(compose 모달): 저장 payload에 time("HH:MM", 예정 시작 시간 — 미지정 시 없음)·editSessionId(계획 수정 시 원본 id — update로 처리, 없으면 insert) 추가 (2026-07-15, Codex 후속)
  • 완료 기록(backfill)도 동일 모달 — onSaveBackfill(date, { title, date, startTime, endTime, exercises }) (2026-07-15): 시작/종료 시간 필수(미입력 시 시간 박스 빨간 하이라이트+저장 중단), 세트에 rest/freeRest 포함. 구 UiDesktopWorkoutFlow 임베드 흐름 대체.
  • 자유 기록 엔트리 (2026-07-20): exercises[]{ kind:"note", name(제목 ≤40자), note(메모 ≤500자) } — sets 없음, 볼륨/세트/종목 수 집계 미기여. backfill 모드에선 일반 종목과 동일하게 review?(종목 리뷰, field workout.exercise.review)를 편집·왕복. 조회(세션 상세 모달)는 메모장 블록(.dk-memo) + 리뷰 말풍선(.dk-ms-review).

v49 계획 편집 배선

  • 계획 컴포저 입력은 favorites: string[], oneRms: Record<exerciseId, kg>를 포함한다. 1RM 조회도 canonical exercise UUID만 사용한다.
  • 계획 수정 저장 draft는 editSessionId를 포함하고, 예정 시각을 입력했다면 time: "HH:mm"을 포함한다. 컨테이너는 같은 session(status planned) 행을 교체하며 사용자가 시각을 변경하지 않으면 기존 값을 보존한다.
  • kg/lb/% 모드는 UI-local이지만 % 원본은 loadPct, lb 원본은 loadLb, 계산된 절대 중량은 load로 함께 저장하고 다시 편집할 때 round-trip 한다.

맨몸운동 체중 반영 (2026-07-19)

  • 새 prop bodyWeight?(kg, 최신 신체 정보 몸무게 — 컨테이너 공급). 종목 draft에 bwFactor?(체중 계수): 저장된 세션 스냅샷을 우선하고, 새 기록은 canonical 카탈로그의 명시 계수를 사용한다. 정규 맨몸 종목은 0 / 0.25 / 0.5 / 0.75 / 1.0의 검토된 프로필을 사용하며 이름·slug·장비 문자열로 추론하지 않는다 (ui/shared/bodyweightSemantics.ts).
  • 유효 세트 무게 = load(추가 중량) + bodyWeight × bwFactor. load 입력값은 추가 중량 그대로 영속. onSave(draft) 종목에 bwFactor 포함 — 세션 영속 시 session.bodyWeight(저장 당시 체중 스냅샷)와 함께 저장할 것(과거 기록 고정).
  • UI: 카드 헤더 "체중 100%" 칩(.dk-bwtag) · 중량 placeholder "추가 중량" · 확정 카드 무게 "체중+10kg × 8"(툴팁 = 유효 kg) · e1RM은 유효 무게 기준, 1RM 대비/세트 강도·% 입력 모드는 미노출(등록 1RM은 추가 중량 기준이라 상이).
  • UiDesktopWorkoutFlow(라이브 기록)도 동일 계약: bodyWeight prop, 볼륨 유효 무게 합산, buildSession 결과에 bodyWeight/종목별 bwFactor 포함.

수행 디테일(변형 태그) (2026-07-18 · main 미머지분 2026-07-19 재적용)

  • 종목 draft exercises[].details?: string[](최대 3) — 같은 종목에 붙는 수식 태그(통계 귀속 exerciseId 불변, 표시만 구분). 에딧 중 종목명 앞 칩 + "+ 디테일" 팝오버(프리셋+직접 입력), 확정/조회 표기는 "디테일 종목명" 평문.
  • 복합 종목은 슬롯별 composite[].details?: string[] — 자동 종목명에 포함("1 무릎높이 정지 3초 데드리프트 + …"), fan-out 시 해당 exerciseId로 귀속.
  • 저장/조회 왕복 보존(영속은 Codex). 프리셋 v1 하드코드 — 관리자 디테일 카탈로그 공급으로 교체 예정. hook exerciseDetailAdd.

UiDesktopAdmin (운동 관리자)

  • view + onChangeView(view) controlled 계약. 독립 관리자 셸(src/react/vite/admin/AdminStandaloneRoot.tsx, barbelic-docs /admin)의 사이드바가 ADMIN_VIEW_TABS를 재사용해 전체 종목/그룹/디테일/계층/매핑/인입/운영 콘솔/신고 큐를 전환한다(미주입 시 UI-local 폴백). 데스크톱 앱 사이드바의 관리자 하위 메뉴는 2026-08-21 이관으로 제거.
  • 하나의 관리자 페이지에서 다음 뷰를 전환:
    • 운동 분류 (UiDesktopAdminExercisesBody, 3-pane: 아키타입 → 하위 운동 → 디테일 패널)
    • 디테일 (UiDesktopAdminDetailsBody, 수행 디테일 카탈로그, 2-pane)
    • 신고 큐 (UiDesktopAdminReportsBody, 2026-08-25 이슈 #670 — UGC 신고 대기/조치/기각 필터 + 기각·내리기(원본 불변)·사용자 정지/해제, 서버 get_admin_content_reports_v1/admin_resolve_content_report_v1/admin_set_user_suspension_v1, 상태는 DesktopAdminFeature 소유, hook adminReportsView)
  • 부모 UiDesktopAdmin(DesktopAdmin.jsx)이 공유 셸 소유 = 토픽바(브랜드 "운동 관리" + 세그먼트 탭 + 검색 + 뷰별 카운트). body는 .adm-body만 렌더.
  • 전체 유저 공유 글로벌 데이터 — 개인 데이터(1RM/PR/즐겨찾기 등) 없음.
  • 루트 .adm(자체 베이스 토큰 → .dk-app 불필요). 실 앱은 사이드바 없이 full-bleed 렌더 권장. 프리뷰 하니스는 dev 사이드바 옆에 .adm(flex:1)로 배치.
  • props(부모가 받아 body로 그대로 전달):
    • archetypes [{ id, ko, en, part, pattern, children:[exercise] }]
    • unfiled [exercise] — 아키타입 미배정(매핑 대상)
    • reviewQueue [{ id, ko, en, submittedBy, at, count, kind, suggestion, note }] kind: duplicate(병합 후보) | map(아키타입 매핑) | new(신규 승인) — 큐 상세/모달은 별도(미구현)
    • exercise = { id, ko, en, equip(분류 8종: 바벨/덤벨/케틀벨/머신/밴드/맨몸/유산소/기타 — exercise_category 라벨), part, unit(kg/회/초/거리), side(양측/편측), kind(컴파운드/고립), aliases:[string](교정 별칭 = default synonym의 aliases), synonyms:[{ id, name, nameEn, isDefault, aliases:[string] }](표기 트리 전체 — 대표 포함, 표기별 소속 별칭까지. 이슈 #963에서 { name, nameEn } 부분 투영을 확장), recordingFields:[load|reps|distance|duration](기록 필드 프로필, 읽기 전용), origin(system|user — #1175: 소유자 파생값), details:[string], mains:[string], subs:[string], status(normal|inactive), tags?:[string] } ← 개인 측정값 없음 · 2026-08-22 정리: 장비 → 분류(8종), 별칭 → 교정 별칭 + 다른 표기(synonym 트리 2.0.0), 기록 필드·출처 행 추가. Wodup 매핑 뷰는 Wodup → Barbelic 단방향 고정(방향 토글·adminMappingDirToggle 제거). · details = 정지·템포·구간 변형 등 수행 디테일(예: "3초 정지", "네거티브", "무릎 정지"). 종목 디테일 패널에서 별칭과 태그 사이 섹션. 정규 원본은 details 카탈로그.
    • details [{ id, ko, en?, type, desc, status(normal|inactive), usedBy:[{ id, ko, en, equip, archetypeKo }] }] — 디테일 탭의 카탈로그. type ∈ "템포"|"이센트릭"|"구간 정지"|"구간 변형"(TYPE_ORDER 순 그룹핑). usedBy = 이 디테일을 부착한 운동(컨테이너가 exercise.details 역참조로 구성).
    • 상단 카운트(운동 분류: 아키타입/정규/검수/미분류 · 디테일: 디테일/유형/미사용)는 props에서 파생.
  • 콜백(전부 옵셔널, no-op safe):
    • 운동 분류: onAddArchetype() onAddExercise(archId) onOpenReviewQueue() onEditName(id) onMove(id)onMerge(id) onToggleActive(id) onAddAlias(id) onRemoveAlias(id, alias)onAddExerciseDetail(id) onRemoveExerciseDetail(id, detail) onAddTag(id)
    • 표기 편집(이슈 #963, 상세 "표기와 별칭" 카드): onAddSynonym(exerciseId, {ko, en}) onRenameSynonym(synonymId, {ko, en})onReplaceSynonymAliases(synonymId, aliases) onPromoteSynonymAlias(exerciseId, alias)onDeleteSynonym(synonymId) onSetDefaultSynonym(synonymId) — 콜백 부재 시 카드가 읽기 전용으로 렌더.
    • 디테일 카탈로그: onAddDetail() onEditDetail(id) onMergeDetail(id) onToggleDetail(id)onOpenExercise(exerciseId) (→ 운동 분류 뷰의 해당 종목으로 라우팅, 컨테이너/Codex 소유)
  • 선택(뷰/아키타입/종목/디테일)·검색어는 UI-local. 데이터 형태 = fixtures/adminExercisesFixture.js + fixtures/adminDetailsFixture.js
  • 마커: 신규 action/view 마커 0건(등록 전 — 아래 요청). 식별 hook만: adminView(root) adminViewTabs · 운동분류 adminExerciseDetail/adminArchetypeList/adminChildList/adminReviewQueue/adminAliasEditor/adminExerciseDetailEditor(디테일 부착) · 디테일카탈로그 adminDetailList/adminDetailInspector/adminDetailUsage. 드래그 재분류는 포인터 핸들(액션 마커 아님).

UiDesktopAdminImportBody (Wodup 세션 인입 · 업로드/진행/결과)

관리자 페이지 "Wodup 인입" 탭 body. 12단계 파이프라인 전체의 관리자 UI를 하나의 phase 기계로 표현. 순수 프레젠테이션 — 파일 읽기·집계·Storage 업로드·Edge Function·batch polling은 전부 컨테이너(Codex Frontend) 소유.

  • phase "idle"(드롭존·1단계) | "checking"(사전검사·2단계) | "ready"(통과) | "invalid"(실패) | "processing"(서버 처리·4~11단계) | "done"(결과·12단계)
  • file { name, size(bytes), lines? } | null
  • checks [{ k:label, status:"pass"|"fail"|"pending", detail? }] — 사전검사 5종: 파일 확장자(.jsonl)/첫 줄 JSON 파싱/source=wodup/schema_version/wodup_user_id 일관성
  • summary { wodupUserId, sessionCount, exerciseCount, setCount, dateRange } | null — phase==="ready"에서만. 파일에서 감지 가능한 값만
  • progress { pct?(0~100, 생략 시 indeterminate 바), message?, stages:[{ k, label, status:"done"|"active"|"pending", detail? }] } — phase==="processing". 스텝 권장: 업로드→정규화→매핑/placeholder→일지반영→통계
  • result { status:"completed"|"completed_with_placeholders"|"failed", sessionCount, exerciseCount, setCount, mappedCount, placeholderCount, errorCount, placeholders:[{ id(=provider_exercise_key), name, category?, sessions, sets }], errors?:[{ line?, message }] } — phase==="done"
  • providerLabel string (생략="Wodup")
  • 콜백: onPickFile(File) · onStartImport() · onReset()(다른 파일) · onNewImport()(결과→새 인입, 생략 시 onReset) · onGoMapping()(placeholder→Wodup 매핑 탭, DesktopAdmin이 setView("mapping")로 기본 배선) · onRetry()(failed 재시도)
  • 드롭존 dragover 하이라이트는 UI-local. 데이터 형태 = fixtures/adminImportFixture.js(idle/checking/ready/invalid/processing/done/doneClean/failed)
  • DesktopAdmin 통합: TABS{k:"import", l:"Wodup 인입", ic:"upload"}. props는 importPhase/importFile/importChecks/importSummary/importProgress/importResult + onImportPickFile/onImportStart/onImportReset/onImportNew/onImportRetry. import 뷰 카운트 = result(세션/정식매핑/placeholder) > summary(세션/운동/세트) > 상태 칩.
  • 마커: 식별 hook: adminImportView(root) · adminImportDrop · adminImportFile · adminImportStart · adminImportPolicy · adminImportProgress · adminImportResult · adminImportPlaceholders.
  • 인입 대상 계정 (2026-08-21 · 관리자 대리 인입): 업로드 단계(idle/checking/ready/invalid) 상단에 대상 계정 카드. 기본은 본인, 검색해 다른 유저를 고르면 그 계정 명의로 배치가 만들어진다(서버 권한 = is_lift_guild_admin(), 배치 initiated_by_user_id에 관리자 기록). processing/done에서는 드래프트에 고정된 대상(importTargetUser)을 한 줄로 표시.
    • props: importTarget { id, email, displayName } | null(=본인) · importTargetUser(배치에 고정된 대상, 처리 중/결과 표시용) · importTargetQuery · importTargetResults [{ id, email, displayName }] · importTargetStatus "idle"|"searching"|"error" · importTargetError
    • 콜백: onImportTargetSearch(query) · onImportTargetSelect(user) · onImportTargetClear()
    • 마커: hook adminImportTarget · adminImportTargetResults, action admin.import.target.select(option=user id) · admin.import.target.clear, field admin.import.target.query. 인입 시작 버튼 라벨은 대상이 있으면 "〈대상〉 계정에 인입 시작".
  • Codex 후속(마커 등록 요청): action admin.import.pick · admin.import.start · admin.import.reset · admin.import.new · admin.import.retry · admin.import.goMapping. — 2026-08-24 현황: admin.import.goMapping을 제외한 5종은 designContract에 등록 완료(goMapping은 여전히 DesktopAdmin 기본 배선·마커 없음).

UiDesktopAdminMotraImportBody (모트라 세션 인입 · 업로드/진행/결과 — 2026-08-24)

관리자 페이지 "모트라 인입" 탭 body. 입력은 모트라 export XLSX(브라우저에서 블록 스캔 후 변환기 코어 convert-core.mjs 를 그대로 실행) 또는 변환기 산출물 motra-normalized.jsonl — 정규화 정본은 scripts/motra-import 변환기이고 화면은 검사·대상 지정·저장만 한다. 서버 배치·폴링·Edge Function 없음: 브라우저 사전검사(motraNormalizedUpload.ts) 뒤 import_motra_workouts_v1 RPC 1회로 일지 반영+통계까지 끝난다. 순수 프레젠테이션 — 검사·RPC 호출은 컨테이너(motraImportController.ts) 소유. CSS는 .adm-imp-* 전량 재사용(신규 클래스 0).

  • phase "idle" | "checking"(jsonl 사전검사) | "ready" | "invalid" | "processing"(RPC 1회) | "done"
  • file { name, size(bytes), lines?(=세션 수) } | null
  • checks [{ k:label, status:"pass"|"fail"|"pending", detail? }] — 사전검사 5종: 파일 형식(.xlsx/.jsonl)/구조·변환(변환기 코어)/세션 identity/판정 매니페스트(map·new slug, custom motra_name)/규모 제한
  • summary { sessionCount, exerciseCount(엔트리), setCount, weightsCount, cardioCount, noteCount, catalogCount, customCount, dateRange } | null — phase==="ready"
  • progress { message?, stages:[{ k, label, status, detail? }] } — phase==="processing", indeterminate 바 고정(서버 신호 없음)
  • result { status:"completed"|"failed", sessionCount, exerciseCount, setCount, weightsCount, cardioCount, noteCount, catalogCount, customCount, customExercises:[{ id(=motra:* key), name }], errorCount, errors?:[{ message }] } — phase==="done". custom 실체화 종목 카드가 와드업의 placeholder 카드를 대체(오너 D1).
  • 콜백: onPickFile(File) · onStartImport() · onReset() · onNewImport()(생략 시 onReset) · onRetry(). goMapping 없음 — 판정은 매니페스트가 정본이라 후속 매핑 검수 개념이 없다.
  • 인입 대상 계정: 와드업의 ImportTargetCard를 그대로 재사용(→ DesktopAdminImport.jsx에서 export). props/콜백/마커 규약 동일 — props: importTarget/importTargetUser/importTargetQuery/importTargetResults/importTargetStatus/importTargetError, 콜백 onImportTargetSearch/Select/Clear. DesktopAdmin 배선에서는 motraImportTarget*/onMotraImportTarget* 이름으로 전달된다(와드업 상태와 독립).
  • DesktopAdmin 통합: TABS{k:"motraImport", l:"모트라 인입", ic:"table"}. props는 motraImportPhase/motraImportFile/motraImportChecks/motraImportSummary/motraImportProgress/motraImportResult + onMotraImportPickFile/onMotraImportStart/onMotraImportReset/onMotraImportNew/onMotraImportRetry.
  • 마커: 식별 hook adminMotraImportView(root) · adminMotraImportDrop · adminMotraImportFile · adminMotraImportStart · adminMotraImportPolicy · adminMotraImportProgress · adminMotraImportResult · adminMotraImportPlaceholders(custom 실체화 카드), action admin.motraImport.pick|start|reset|new|retry(designContract 등록 완료). 대상 계정 카드 마커는 와드업 것 공유(adminImportTarget 등).

공통 로딩 오버레이 (DesktopLoadingOverlay — 2026-07-11)

  • 공용 컴포넌트 desktop/screens/DesktopLoadingOverlay.tsx: UiDesktopLoadingOverlay({ loading, label?, variant?: "card"|"section"|"item"|"inline", className? }) + UiDesktopLoadingPill + dkLovIdSet.
  • 방식: 호스트 부모에 .dk-lov-host(position:relative·min-w/h:0) + 로딩 중 data-lov-busy="1" → 오버레이는 absolute inset:0 불투명 var(--surface) 커버(부모 경계·카드 크기 무변, 레이아웃 이동 0). busy 시 호스트의 다른 자식은 CSS visibility:hidden — 뒤 콘텐츠 마우스·키보드(포커스) 차단. role="status" aria-live="polite" aria-busy="true". label은 전달값 그대로(화면별 하드코딩 없음, 미전달 시 스피너만). 스피너/필 CSS는 desktop.css 말미 .dk-lov* 한 번만 정의. .lg-data-loading-overlay(전체 화면 fixed) 미사용. 신규 data-lg-* 마커 0.
  • 운동일지 UiDesktopSessionScreen: selectedDateDetailLoading+selectedDateDetailLoadingDate(=== selectedDate일 때만)+selectedDateDetailLoadingLabel → 우측 하루 패널(.dk-j2-bar 아래)만 가림(달력·필터 바 무변). monthLoading(현재 표시 월)+monthLoadingKeys(["YYYY-MM"])+monthLoadingLabel → 단일 월 셀 자리(주 수×97px 밴드 .dk-j2-mload)만 가리고, 전경 카드 로딩과 인접 월 background prefetch를 구분한다. sessionDetailLoadingIds는 레일/달력 엔트리의 중복 요청만 막으며 행 오버레이는 그리지 않는다. sessionDetailPendingId(2026-07-18 지시서): 상세 응답 대기 중인 세션 id 1건 — 현재 날짜에 속한 정확한 ID일 때만 세션 상세 모달(journalSessionModal)이 클릭 즉시 열리고, selectedSession 도착 전까지 본문에만 section 로더(.dk-j2-sess-pending min-height 460px)를 표시한다. active selectedSession이 pending보다 우선하며, 닫기 X·스크림·같은 행 재클릭 판정은 loading guard보다 먼저 실행한다. 컨테이너 책임: 닫힘/다른 세션 선택 후 도착한 스테일 응답은 selectedSession으로 올리지 않기, 캐시 적중 시 pending 생략.
  • 훈련 리포트 UiDesktopVolumeScreen: loading+loadingLabel.dk-vol-bodymain 카드만 가림(사이드바·배경 유지, 0수치/빈차트 미노출). sessionDetailLoadingIds/Label은 컨테이너 호환을 위한 예약 prop이며, 현재 v4 레이아웃에는 세션 오픈 행이 없어 소비하지 않는다.
  • 메인세트 모아보기 UiDesktopLogTableScreen: loading+loadingLabel.dk-logt-scrollwrap(표 콘텐츠)만 가림 — 월 네비게이터·볼륨/탑세트 토글 사용 가능, 열 폭·행 높이 유지(visibility 기반). 종목 드로어의 슬림 DkPrDetail은 기록 행을 렌더하지 않으므로 세션 상세 로더 props를 받지 않는다.
  • 나의 기록 UiDesktopPrScreen: loading+loadingLabel.dk-prb-card 내부만 가림(빈 즐겨찾기/0 PR 보드 선노출 방지). detailLoading은 상세 root의 aria-busy만 갱신하고 시각 오버레이를 추가하지 않는다. sessionDetailLoadingIds/Label → E2 PR 행 .pb, E3 탑세트 행 .tb, F 훈련 목록 행 .fh의 item 로더와 클릭 가드(r.sessionId ?? r.id 정확 매칭). E2/E3은 세션으로 이동하고 F는 세트 상세 모달을 먼저 연다.
  • 프로필 피드 UiDesktopHomeScreen: profileFeedInitialLoading+profileFeedLoadingLabel.dk-h3-feed 컬럼만 오버레이(최초 로딩 중 "아직 기록이 없어요" 미표시). 추가 페이지 profileFeedLoading은 기존 하단 인라인 로더(#177) 유지 — 기존 피드 미가림. sessionDetailLoadingIds/Label → 클릭한 포스트만 item 로더.
  • 세션 검색 UiDesktopSearchScreen: sessionDetailLoadingIds/Label → 클릭한 결과 포스트만 item 로더.
  • 공통 규칙: 로더는 props만 신뢰(자체 타이머 없음 — 캐시 적중/오류 종료 시 컨테이너가 상태를 내리면 즉시 소멸). 서로 다른 두 세션 동시 로딩 가능(Set 포함 판정). 상위 카드/날짜 상세 오버레이 표시 중엔 세션별 로더 숨김.

신규 마커 요청

  • Admin (Codex 등록 요청) — 현재 식별 hook만 부착, 실제 action/view/field 마커 미등록:
    • view: dashboard.admin (단일 페이지, 뷰 전환은 UI-local 탭)
    • action(운동 분류): admin.view.tab · admin.archetype.add|select · admin.exercise.add|select|editName|move|merge|toggleActive · admin.alias.add|remove · admin.exercise.detail.add|remove · admin.tag.add · admin.review.open
    • action(디테일 카탈로그): admin.detail.add|select|editName|merge|toggleActive · admin.detail.openExercise
    • field: admin.search
    • URL 전용 라우트 배선(메뉴 비노출)은 app-shell 소유 = Codex. 디자인 측은 화면/props 계약만 제공.
  • (이전) 데스크톱 1차는 전부 기존 등록명으로 커버. 미커버 인터랙션은 기능 확정 시 PR 설명으로 등록 요청.

U03 공통 편집기·창 크기 대응 (2026-09-10, #1421)

적용 대상은 release/v0.18.0, Production 미반영이다. 작업·검증 기록이 기존 고정 크기·내부 스크롤 설명보다 우선한다.

  • 계획은 A13의 PlanEditorState, 완료 기록 작성·수정은 A12의 WorkoutEditorState를 직접 소유한다. UiDesktopPlanEditor/UiDesktopWorkoutEditor는 같은 카드 표현을 쓰되 typed command adapter와 저장 의도를 분리한다. 완료 기록을 계획 모델로 열거나 backfill extra를 다시 합치는 활성 경로는 제거했다.
  • 화면은 raw 분·초/무게/복합 입력과 IME 조합 여부를 명령으로 보낸다. 입력 해석·단위 환산·세트 판정·실패·자식 ID·저장 투영은 공통 모델이 담당한다. 표시 단위·focus·drag·picker 열림은 화면 상태다. 기존 서버 점수·통계는 보존하되 의미가 바뀐 입력의 statsLoad/statsEffectiveLoad는 공통 reducer가 무효화한다.
  • publishDesktopWorkoutSnapshot(requestId, snapshot)은 완료 기록 편집의 최신 원문을 S08에 전달한다. 저장 시작 뒤 바뀐 입력은 별도 초안 operation으로 남긴다. durable queue/receipt가 이전 저장을 정리해도 최신 원문을 지우지 않고, receipt 자식 ID·revision을 채택한다. owner가 바뀌면 화면을 닫고 이전 owner 늦은 응답이 새 화면을 바꾸지 못하게 한다. 계획의 지연 저장도 최신 입력을 유지하고 다음 저장은 생성된 계획을 수정한다.
  • recoveryPanel은 S09 typed 상태와 명령만 받는다. 현재값 미조회·권한 없음·오프라인·미리보기 stale·기기 보관·서버 확정을 구분한다. 명시 확인 전 적용하지 않으며 서버 미조회 값을 계산으로 채우지 않는다.
  • 홈·일지·리포트는 document 스크롤과 가용 본문 폭으로 재배치한다. 홈 피드 추가 요청은 document viewport 기준이다. PR 보드의 열 구조만 dk-prb-table-scroll(키보드 focus 가능한 region)에서 가로 스크롤한다. 통계 값·컬럼 의미는 변경하지 않는다.
  • 편집 compose/폐기 확인은 U01 UiDialog다. dialog 자체가 긴 내용을 스크롤하고 focus를 가둔다. 디테일 picker는 열린 compose 안에 portal을 두며 topmost Escape를 소비한다. 저장 중 폐기 방지와 owner 변경 시 닫힘은 화면/컨트롤러가 담당한다.