페이지 루트에 BackColor 가 없을 때 내가 임의로 'White' 를 지어내 보여 주고 있었다.
페이지 루트는 WinForms UserControl 이고 그 BackColor 기본값은 SystemColors.Control 이라
레거시 PropertyGrid 는 'Control' 을 표시한다 — 사용자가 짚은 그대로다.
근거를 위해 --db-pageprops 진단을 신설해 전수 집계했다(디자인 1,271건 / 페이지 1,816장).
BackColor White 1,487장(81.9%) · (없음) 230장(12.7%) · Window 91장(5.0%)
ButtonHighlight/ControlLightLight/MenuBar/Control/ControlLight 8장
Font (없음) 95.6% — 없을 때 굴림 9pt 로 보이는 현행 기본값이 맞다
기타 키 Margin 9장, BorderStyle 6장, DoNotPrintThisPage 2장 …
12.7% 는 예외가 아니라 흔한 경우다. 표시만 고치고 저장은 하지 않는다 — 지어낸 값이 XML 에
새로 생기면 왕복 diff 0 이 조용히 깨진다. 이 규약을 두 겹으로 못 박았다.
- 편집 스모크: 값 없는 페이지에서 Control 표시 / 미저장 / 용지는 흰색
- --db-pageprops: BackColor 없는 실제 서식 40건을 열어 XML 이 그대로인지 대조
렌더는 흰색을 유지한다. 사용자 확인 결과 레거시 실행 시에도 흰색으로 나오고,
#F0F0F0 종이는 라이트 테마 캔버스 배경(#D8D8D8)과 1.13:1 이라 용지 경계가 사라진다.
대신 값이 없을 때는 라벨 툴팁으로 '저장된 값 없음 / 실행·인쇄는 흰색 / 바꾸면 그때 저장'을
알린다 — 안 그러면 "Control 인데 왜 희게 보이지?" 로 다시 읽힌다.
부수: PropertyRowViewModel 에 Hint 를 추가하고 색상 행 라벨 툴팁에 배선.
회귀: 테스트 124/124, 편집 스모크 실패 0, DB 왕복 1,271건 diff 0/예외 0,
종이 렌더 P062 바이트 동일.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
레거시에서 페이지는 MDesignerHost 라는 하나의 Object 이고 자기 속성을 갖는다.
SheetMe 도 그 루트 노드를 FormPage.Root 로 통째로 보존해 왔고(그래서 운영 1,271건 왕복
diff 가 0이다), 값도 일부는 이미 쓰고 있었다 — BackColor 는 용지 배경으로 렌더하고
Font 는 자식이 상속한다. 없던 것은 편집 화면뿐이었다.
그 결과 용지 크기조차 바꿀 수 없는 상태였다. ApplyPaperSize 는 구현돼 있었지만 호출처가
편집 스모크뿐이고 메뉴·XAML 어디에도 배선이 없었다 — 새 서식은 720×856 고정으로만 시작했다.
레이어 아웃라인의 페이지 헤더 행을 누르면 선택을 비우고 속성 패널이 페이지 모드로 바뀐다.
컨트롤을 하나라도 고르면 자동으로 빠져나온다.
용지 너비·높이(한 줄), 배경색
기본 글꼴 글꼴·크기 — 여기를 바꾸면 Font 를 명시하지 않은 컨트롤이 전부 따라온다
전체 속성 나머지 원본 속성(레거시 PropertyGrid 등가)
저장 규약을 지켰다. writer 는 저장할 때 Size 를 Bounds 에서, Name 을 Id 에서 다시 만들어
덮으므로 그 두 키는 raw 편집 대상에서 뺐다 — 열어 두면 고쳐도 저장하면 되돌아간다.
크기는 raw Size 가 아니라 Bounds 를 고치고, 편집 결과가 저장 XML 의 Size 와 같은지
스모크로 못 박았다.
용지 크기가 바뀌면 페이지 스택 오프셋과 월드 크기를 다시 계산한다(여러 장이면 한 장의
높이 변화가 아래 페이지를 전부 밀어낸다). 기본 글꼴이 바뀌면 그 페이지 컨트롤의 상속
컨텍스트를 다시 흘린다.
편집 스모크에 페이지 속성 검사 11건 추가.
회귀: 테스트 124/124, 편집 스모크 실패 0, DB 왕복 1,271건 diff 0/예외 0,
종이 렌더 P062 바이트 동일.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
다크 테마에서 흰 시스템 창이 튀어나오던 알림을 자체 창(MessageDialogView)으로 바꿨다.
앱 셸과 같은 커스텀 타이틀바(36px) + B.Surface 본문 + 성격 아이콘 + 우측 버튼 줄.
폭 440 고정, 내용에 따라 높이 자동, 긴 예외는 스크롤로 흡수한다.
진입점은 DialogService 정적 메서드 4개로 모았다 —
Notify / Confirm / ConfirmWithCancel / ShowError.
MessageBox 는 Win32 호출이라 아무 데서나 되지만 WPF 창은 아니다. 교체로 새로 생기는
제약 셋을 DialogService 한곳에서 막는다.
- 진단 모드에서는 창을 만들지 않고 기각 기본값을 즉시 돌려준다. 스모크는 무인 실행이라
모달이 하나라도 뜨면 타임아웃 없이 영원히 멈춘다 — 실제로 편집 스모크가 지나는 경로에
붙여넣기·개명 가드가 있다. 규약을 스모크 검사 3건으로 고정했다.
- UI 스레드가 아니면 Dispatcher 로 넘긴다.
- 소유 창은 '살아 있는 것'만 건다. 아직 안 보였거나 이미 닫힌 창을 Owner 로 주면 예외이고,
진단 렌더러가 도는 동안 MainWindow 가 닫힌 창을 가리킬 수 있다.
App.xaml.cs 4곳은 다르게 처리했다.
- 기동 실패(:49)·예외 폭주(:82)·복구 안내(:89) 3곳은 순정 유지. 창이 없는 시점이라
자체 창을 띄우면 종료코드가 유실되고, 이미 예외가 터진 자리에서 WPF 창을 새로 만들면
같은 핸들러로 재진입한다. 이유를 각 자리에 주석으로 남겼다.
덤으로 :89 는 e.Handled 를 알림보다 먼저 세우도록 순서를 바로잡았다 —
알림이 던지면 '복구 가능한 예외'가 하드 크래시로 바뀐다.
- 알 수 없는 진단 옵션(:179)은 알림을 없앴다. 옵션 오타 하나로 무인 실행이 멈추던 자리다.
함께 고친 것 — Primary 버튼 스타일. 공유 버튼 템플릿의 호버 트리거가 TargetName 으로
채움을 회색으로 덮는데(TargetName 트리거는 TemplateBinding 을 이긴다) 글자는 흰색 그대로라,
라이트에서 마우스를 올리면 #F3F3F3 위 흰 글자 1.08:1 로 사라진다. 지금까지 Primary 사용처가
0건이라 드러난 적이 없었고 이 창이 첫 사용이다. 전용 템플릿 + 채움 호버·누름 토큰 2종 신설.
신설 토큰: B.Warning / B.Danger(라이트는 다크값을 못 쓴다 — 앰버 #E0A33A 는 흰 면 위 2.22:1 로
아이콘 기준 3:1 도 미달), B.AccentFillHover / B.AccentFillPressed. 전부 양 테마에 동시 추가.
Lucide 아이콘 4종(info·circle-alert·triangle-alert·circle-help) 추가.
바꾸지 않은 것: 예외 원문. 18곳을 ShowError 로 수렴시키면 화면에서 ex.Message 가
오류코드+로그로 대체되는 동작 변경이 된다 — 요청은 시각 변경이라 문구·정보량을 그대로 뒀다.
남는 시스템 대화상자: 인쇄(PrintDialog)와 파일 열기/저장 — OS 셸 대화상자라 대상이 아니다.
진단 렌더러에 알림 5종(오류·경고·확인·저장확인·정보)을 등록해 라이트/다크 10장이 자동으로 남는다.
회귀: 테스트 124/124, 편집 스모크 실패 0, DB 왕복 1,271건 diff 0/예외 0,
종이 렌더 P062 바이트 동일.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
컨트롤 행의 눈·자물쇠는 호버 규칙으로 바꿔 놓고 페이지 행 버튼만 상시 노출이라
목록 우측이 들쭉날쭉했다. 같은 규칙으로 맞춘다.
버튼 각각이 아니라 묶음(StackPanel)에 거는 이유가 있다 — 삭제 버튼은 CanDelete 에
Visibility 를 물려 있는데 인스턴스의 지역 값이 스타일 트리거를 이기므로, 버튼마다 걸면
삭제만 상시 노출된다. 바깥을 접으면 둘 다 숨고, 펴지면 삭제는 원래대로 CanDelete 를 따른다.
되돌리기가 번거로운 '페이지 삭제'가 항상 노출돼 있지 않게 되는 부수 효과도 있다.
회귀: 테스트 124/124, 편집 스모크 실패 0, DB 왕복 1,271건 diff 0/예외 0,
종이 렌더 P062 바이트 동일.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
이름이 잘려 행을 구분할 수 없었다
눈·자물쇠가 48px 를 상시 차지해 이름 칸이 한글 11자로 줄었다. 운영 서식에는
'입원일 복용약-전립선약' 처럼 앞이 같고 끝에서 갈리는 이름이 흔해 전부
'입원일 복용약-…' 로 잘렸다 — 목록만 보고는 어느 행인지 알 수 없었다.
두 토글을 평소엔 숨기고 마우스를 올린 행에서만 띄운다. 잠김·숨김이 켜진 행에서는
계속 띄운다(거기서까지 숨기면 마우스를 올리기 전에는 상태를 알 방법이 없다).
[200] 은 항상 띄우지만 여기서만 갈라선다 — 폭이 곧 가독성이라서.
라이트 모드 선택 밴드가 너무 무거웠다
양 테마 모두 짙은 파랑(#0F6FBF)에 흰 글자를 쓰다 보니, 흰 배경에 짙은 슬라브가
얹혀 선택 몇 줄이 패널 전체를 짓눌렀다. 라이트만 방향을 뒤집는다 —
연한 채움(#A8D3F7, 흰 패널 대비 1.58:1) + 진한 글자(10.1:1).
서브트리도 한 단계 옅게(#DCEBFB) 해서 '폴더가 주인, 멤버는 딸림'이 색 세기로 읽힌다.
밴드 위 글자색을 B.OnAccent(흰색 고정)에서 신설 토큰 B.OnLayerSel 로 바꿔
테마별로 갈라지게 했다(다크 흰색 / 라이트 #222222). 보조 텍스트도 라이트만
#35536C 로(연한 채움 위 5.12:1 — 기존 #E6F1FB 는 라이트에서 사라진다).
회귀: 테스트 124/124, 편집 스모크 실패 0(레이어 검사 45건), DB 왕복 1,271건
diff 0/예외 0, 종이 렌더 P062 바이트 동일.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
1) 목록에서 단축키가 안 먹었다
CanvasKeyboardBehavior 는 캔버스에 붙어 있어 포커스가 레이어 목록에 있으면 이벤트가
거기까지 가지 않는다. 여러 행을 골라 놓고 Ctrl+G 를 눌러도 아무 일이 없던 이유다.
목록에 KeyDown 을 달아 그룹·삭제·복사·되돌리기를 처리한다. 방향키·Home/End·문자 입력은
넘기지 않는다(목록 자체의 이동과 형식 검색이 우선). 이름 편집 중에는 통과시킨다.
2) 세로 스크롤바가 오른쪽이 잘려 보였다 — 앱 전체 공통 결함
Thumb 스타일의 MinWidth/MinHeight 24 가 폭 9 짜리 막대 안에서 썸을 24 폭으로 강제해
오른쪽이 통째로 잘렸다(둥근 모서리가 사라지고 수직으로 싹둑 잘린 막대). 가로 막대는
아래쪽이 같은 식으로 잘렸다. 최소 '길이'는 MinThumbTrack.MinThumbLength=44 가 이미
보장하므로 최소 '두께'는 지운다.
화살표 글리프도 7 단위 + 스트로크 1.4 = 8.4 라 폭 9 에서 캡이 경계에 닿아 오른팔이
잘렸다 — 막대 폭을 11 로 올려 양옆 여유를 준다.
3) 검색칩 안에 상자가 하나 더 그려졌다
전역 TextBox 템플릿은 BorderThickness=1 이 하드코딩돼 있어 인스턴스에서 0 을 줘도
무시된다. 칩 전용 ChipTextBox 스타일(테두리 없는 템플릿)을 만들고, 포커스 표시는
칩 쪽 IsKeyboardFocusWithin 으로 옮겼다.
회귀: 테스트 124/124, 편집 스모크 실패 0, DB 왕복 1,271건 diff 0/예외 0,
종이 렌더 P062 바이트 동일.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
[200] 의 패널은 코드로 Border 를 쌓는 비가상화 렌더고 SheetMe 는 가상화 ListBox 다.
골격은 SheetMe 것을 지키고 겉모습과 조작 표면만 옮겼다 — 통째로 베끼면 1,067 컨트롤
서식에서 검색어 한 글자마다 수천 비주얼을 다시 만들게 되고, [200] 에 대응물이 없는
컨테이너 자식 재귀 표시가 사라진다.
구조
- 페이지를 별도 섹션이 아니라 아웃라인의 최상위 노드로. 접기·복제·삭제·컨트롤 수·현재 강조
- 맨 아래 '+ 페이지 추가' 행
- 목록 순서를 뒤집었다 — 이제 맨 위가 z-order 최상위(캔버스에서 위에 겹쳐 보이는 것이 목록 위)
- 들여쓰기 12 / 32 / +16
- 레이어 패널을 LayerPanelView 로 분리(MainView.xaml 이 1,000줄을 넘겼고, 진단 렌더러가
이 패널만 따로 찍어 라이트·다크·선택 상태를 검사할 수 있어야 한다)
선택 표시
- IsSelected 가 아니라 Highlight 3상태(None/Blue/Navy)가 배경을 그린다. 그룹 전원 선택 시
폴더는 파랑, 멤버는 서브트리 배경이 되고 맞닿는 모서리를 각지게 해 하나의 블록으로 이어진다
- 밴드 위에서는 아이콘·보조 텍스트 색도 함께 뒤집는다. 안 그러면 눈·자물쇠가 1.6:1 로 묻혀
선택한 행에서만 잠금 상태가 안 보이는 상태가 된다
- 신설 토큰 3종(B.LayerSel / B.LayerSubtree / B.OnSelMuted) — [200] 원본값은 각각
흰 글자 3.00:1, 다크 밴드 1.03:1, 라이트 밴드 1.14:1 로 이 저장소가 이미 기각한 값이다
기능
- 드래그로 z-order 재정렬(삽입선 어도너). 임의 index 이동 연산 MoveWithinParent 신설
- 페이지 복제 — 컨트롤 이름을 전부 새로 발급하고 그룹도 복제본끼리 다시 묶는다
([200] 은 GroupId 를 그대로 둬서 복제 그룹이 원본과 gid 를 공유한다)
- 페이지 헤더 클릭 → 캔버스가 그 페이지로 이동(ScrollToPage)
- 데이터 배선 배지 — 값이 조회·데이터소스에서 오는 컨트롤 표시
- 검색칩에 테두리(라이트에서 B.Input=B.Panel 이라 경계 대비가 1.00:1 이었다)
이식하지 않은 것: 컨트롤 행 인라인 개명. [200] 이 고치는 값은 표시 전용 별칭이지만
SheetMe 의 컨트롤 이름은 외부 참조 키라 목록에서 무심코 고치면 배선이 끊긴다.
더블클릭은 요청대로 캔버스 이동을 유지하고, 그룹 행에서만 이름 편집으로 간다.
동반 수정: 눈·자물쇠 토글에 되돌리기 지점 추가(400ms 코얼레스), 그룹 이름 비우기 경로가
스냅숏 없이 지워지던 것, 숨김 흐리기를 행 전체가 아니라 이름 줄에만.
편집 스모크의 레이어 검사 19 → 42건. 회귀: 테스트 124/124, 편집 스모크 실패 0,
DB 왕복 1,271건 diff 0/예외 0, 종이 렌더 P062 바이트 동일.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
[200] 의 그룹도 컨테이너가 아니라 GroupId 태그 + 이름 사전이었다. SheetMe 가 이미
같은 구조(ControlElement.GroupId, JsonIgnore)를 갖고 있어 저장 포맷은 그대로 두고
표시·조작 계층만 얹는다 — 왕복 XML 에는 아무 흔적도 남지 않는다.
레이어 목록을 평탄화 결과(LayerRows)로 바꿨다. 그룹 폴더 행과 컨트롤 행이 한
시퀀스로 섞이고, 계층은 들여쓰기 여백으로만 표현한다(TreeView 를 쓰면 가상화와
기존 행 템플릿을 다 버려야 한다 — 인스펙터 섹션이 쓰는 방식과 같게 맞췄다).
새로 되는 것
- 그룹 폴더 행: 접기/펼치기, 멤버 수, 이름 인라인 편집(더블클릭), 그룹 전체 눈·자물쇠
- 그룹 이름 — FormDocument.GroupNames(편집 전용). 비우면 기본 그룹 으로 복귀
- 컨테이너(패널/그룹박스) 자식이 레이어에 보인다. 지금까지는 아예 접근할 수 없었다
- 목록 다중선택(Shift/Ctrl) — 캔버스 선택과 양방향 동기
- 타입 필터: 그 페이지에 실제로 있는 타입만 후보로 띄운다
- 검색 0건·빈 페이지 안내 문구
함께 고친 결함
- 붙여넣기가 원본 그룹에 끼어들었다(Clone 이 GroupId 를 그대로 복사) → 복제본끼리 새 그룹
- 그룹 해제가 선택된 멤버만 풀어 1개짜리 반쪽 그룹이 남았다 → 그룹 전 멤버를 푼다
- 멤버가 1개 이하로 줄어든 그룹이 계속 남았다 → CleanupGroups 자가치유
- 셰브론이 늘 눌린 것처럼 보였다(IsChecked 채움) → MiniChevron 스타일 분리
--edit-smoke 에 레이어 검사 19건 추가(그룹 위생·평탄화·필터·XML 무영향).
회귀: 테스트 124/124, edit-smoke 실패 0, db-smoke 1,271건 diff 0/예외 0,
종이 렌더 P062 바이트 동일.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
레이어 목록이 Text 만 보여줘서 chkOrientation1~5 처럼 문구가 같은
컨트롤들이 전부 같은 줄로 보였다. 두 줄로 나눠 아래에 Id 를 병기한다
(Text 가 없어 LayerName 이 곧 Id 인 경우는 중복이라 숨긴다).
그리고 컨트롤이 수백 개인 서식에서는 목록에서 이름을 찾아도 종이
어디에 있는지 알 수 없었다 — 더블클릭하면 해당 페이지로 전환 + 선택 +
캔버스 스크롤. 이미 전부 보이면 스크롤하지 않는다.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
사용자 지적: "피그마같은 도구처럼 보기 편하게, 특히 속성 사용하기 불편해".
불편의 핵심은 밀도가 아니라 **조작**이었다. 모든 칸이 UpdateSourceTrigger=LostFocus 하나라
값을 고치고 나서 **다른 곳을 눌러야** 반영됐다 — 캔버스를 보며 숫자를 맞춰 가는 작업이
성립하지 않는다.
[Enter 확정] 즉시 커밋하고 결과를 전체 선택한다(확정 신호이자 곧바로 덮어 칠 수 있는 상태).
여러 줄 입력은 Enter 가 줄바꿈이어야 하므로 AcceptsReturn 으로 코드에서 가른다 — 스타일 예외로
빼면 새 여러 줄 칸이 생길 때마다 잊는다.
[Esc 되돌리기] **편집 전 값을 따로 보관하지 않는다.** LostFocus 트리거라 소스는 아직 손대지 않은
상태이고 소스가 곧 백업이다 — UpdateTarget() 으로 화면만 다시 읽는다.
(초안은 포커스 시점 문자열을 기억했는데, 그러면 칸에 포커스를 둔 채 캔버스에서 컨트롤을 끌었을 때
Esc 가 낡은 값을 되살리고 LostFocus 가 그걸 커밋해 **컨트롤이 원위치로 돌아간다**. 검증에서 잡았다.)
[↑/↓ 증감] 1씩, Shift 10씩. 증분은 칸마다 지정한다 — **Ctrl 미세조정은 기본 끔**이다.
X/Y/너비/높이는 읽기·쓰기 양쪽에서 반올림하므로 0.1 을 더해도 모델은 그대로인데 문자열만 바뀌어,
화면은 그대로면서 Undo 스냅샷과 '수정됨' 표시만 남는다. 소수를 실제로 받는 글꼴 크기에서만 켠다.
그리고 **글꼴 크기는 NumberRow 가 아니라 TextRow 였다** — 정작 가장 미세조정하고 싶은 칸이 스핀
대상에서 빠져 있었다. NumberRow 로 승격했다.
[Undo 폭주 차단] 커밋 1회 = 문서 전체 딥클론 1회다. ↑ 를 스무 번 누르면 스냅샷 스무 개가 쌓여
용량(100)을 갉아먹고, 저장 지점이 밀려나면 영구 '수정됨'이 된다. 행에 CoalesceUndo 플래그를 두고
경계·크기 행만 기존 SnapshotForNudge(400ms 코얼레스)로 보낸다. 값이 아니라 **커밋 방식만** 바꾸므로
Initialize/Commit 규약은 그대로다.
[정렬 격자] 콤보에 "MiddleCenter" 같은 레거시 원문 열거값이 그대로 떴다. 무슨 뜻인지 알려면 아홉 개를
다 열어 봐야 하고 고르고 나서도 글자로만 확인된다. 3열 격자로 바꿔 9값은 3×3 방향 화살표,
3값은 1×3 정렬 아이콘으로 접힌다(같은 템플릿이 값 개수에 따라 알아서).
**ToggleButton 이 아니라 Button 을 쓴다** — 토글이면 켜진 칸을 다시 눌러 해제되고, 그때 값이 빈
문자열이 되어 Set 이 속성을 **삭제**한다. ControlRegistry 주석이 경고하는 '선택만으로 속성이
사라지는' 사고(운영 249건)와 같은 계열이다.
[바꾸지 않은 것] UpdateSourceTrigger 는 그대로 둔다. PropertyChanged 로 바꾸면 한 글자마다 딥클론이
쌓이고, 이름 행은 글자마다 RenameControl 이 돌며, 경계 행은 커밋 결과가 정규화 값으로 되쓰여 캐럿이
튄다. 피그마식 사용감은 트리거가 아니라 명시적 확정 제스처로 얻는다.
[다음 후보 — 이번엔 넣지 않았다] 라벨을 끌어서 값을 바꾸는 스크럽은 UndoService 에
BeginBatch/EndBatch 를 넣어야 안전하다(지금 구조면 드래그 프레임마다 스냅샷 1개). 캡처 해제 처리도
이 저장소에 사고 선례가 있어 별도 작업으로 남긴다.
검증: 테스트 124/124, edit-smoke 실패 0, 왕복 1,271건 diff 0/예외 0, 종이 렌더 P062 픽셀 차이 0.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
사용자 요청으로 [200] 식 「디자인 / 데이터 / 동작」 3탭을 넣었다.
[분류] 속성 49종을 레거시 실사용 근거로 배정했다. 이름만 보고 찍으면 사용자가 속성 하나를 찾아
세 탭을 다 뒤지게 되므로, 각 속성이 레거시에서 실제로 어떻게 쓰이는지를 근거로 삼았다.
· GetValue() 가 그 속성을 돌려주면 값이다 — CheckBox/RadioButton 의 Checked 는 IIf(Checked,'Y','')
이므로 초기값(데이터).
· DateTimePicker.Format/CustomFormat 은 데이터처럼 보이지만 GetValue 가 Format 과 무관하게 항상
yyyyMMddHHmmss 를 돌려준다 — 그리기 문자열만 바꾸므로 디자인. 확정적.
· Mask 는 WinForms 분류로는 Behavior 지만 실제로는 날짜·시간 입력 형식 제약이라 데이터.
· Score 는 '점수'라는 이름과 달리 체크됐을 때만 값을 내는 데이터 항목이고 CalcBox.Formula 가
컨트롤명으로 합산한다.
· 상용구 2종(RwdRsvWrdYon/RwdOrderAutYon)은 입력 보조처럼 보이지만 조작 훅이다 — 전자는 포커스
핸들러 부착 게이트, 후자는 상용구를 고른 결과로 OCS 처방을 전송하는 게이트. **동작**으로 정정했다
(초안에서는 데이터로 뒀다).
결과: 디자인 21 / 데이터 20 / 동작 8.
[빈 탭은 그리지 않는다 — 이번 결정의 핵심]
분류를 마치고 세어 보니 **동작 속성을 가진 타입은 TextBox·Button 둘뿐이고 나머지 14타입(87.5%)에서
동작 탭이 통째로 빈다.** 데이터 탭도 6타입에서 빈다. 빈 탭을 남겨 두면 대부분의 컨트롤에서
'여긴 없습니다'만 보게 되고 사용자는 빈 탭을 계속 눌러보게 된다 — 탭이 없느니만 못하다.
그래서 개수 0인 탭은 알약을 아예 그리지 않고, 남는 탭이 하나뿐이면 알약 줄 자체를 숨긴다.
결과적으로 라벨·선·패널·그룹상자·날짜선택은 탭 없이 평평하게 보이고, TextBox 만 3개가 뜬다.
현재 탭이 새 선택에서 비면 디자인으로 되돌린다 — 디자인은 공통·글꼴이 있어 절대 비지 않는
안전한 착지점이다.
[유지] 선택한 탭은 컨트롤을 바꿔도 유지된다. 데이터 탭에서 여러 컨트롤의 태그를 잇달아 손보는
작업이 흔한데 매번 디자인으로 돌아가면 계속 다시 눌러야 한다([200] 은 매번 리셋된다 — 따르지 않았다).
[탈출구는 쪼개지 않는다] '전체 속성(고급)'의 레거시 원문 키는 분류 근거가 없고 무엇보다 탈출구다.
탭에 가두면 '고급에도 없네' 상황이 생기므로 모든 탭 하단에 그대로 둔다.
[분류표 위치] Core 가 아니라 Designer 계층에 뒀다(PaletteIconCatalog 선례). 탭 구성은 화면 사정이지
서식 데이터의 성질이 아니다. 모르는 키는 편집기 종류로 추론해 사이트 커스텀 속성에 대비한다.
검증: 테스트 124/124, edit-smoke 실패 0, 왕복 1,271건 diff 0/예외 0, 종이 렌더 P062 픽셀 차이 0.
dialog-shots 진단에 데이터·동작 탭 스냅샷을 추가해 세 탭 전부를 양 테마로 확인.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
사용자 지적: "속성패널 디자인 보기 불편한데? 고도화해줘".
[실측한 낭비] 단일 선택 시 '공통+글꼴'만으로 354px 를 쓴다. 표준 행 34px 중 실제 필요 높이는
18px(테두리 2 + 글자 16)뿐이라 행마다 12px 가 공기다. 가장 긴 TextBox 는 25행 801px 로 기본 창
뷰포트(약 790px)를 이미 넘겨 스크롤이 강제됐다.
[쌍 배치] X|Y, 너비|높이를 한 줄 2칸으로. 라벨은 칩 바깥 왼쪽 자연폭 — [200] 의 XYPair 규약
(Grid star/10/star)과 같다. 네 줄이 두 줄이 된다.
구조가 핵심이다: **자식 행은 평범한 행 그대로**이고 컨테이너만 새로 만들었다. 값·커밋·"여러 값"
판정이 전부 자식 안에서 끝나므로 커밋 규약(Initialize 는 Commit 을 내지 않고, 값이 바뀔 때만
Undo 스냅샷 1회)이 한 글자도 안 바뀌고, boundsRows 가 자식을 들고 있어 드래그 중 실시간 갱신
(RefreshBoundsRows)도 그대로다. X 만 갈리고 Y 는 같은 다중선택도 칸별로 옳게 표시된다
(그래서 셀마다 전용 Grid 를 둔다 — 오버레이의 포커스 트리거가 AncestorType=Grid 다).
[스타일 세그먼트] 굵게·기울임·밑줄·취소선을 한 줄 아이콘 토글로. 종전에는 굵게·밑줄만 있었고
각각 한 줄이었다. **기울임·취소선은 기술적 제약이 아니라 단순 누락이었다** — LegacyFont 의
파싱·직렬화·렌더·폰트 일괄 변경 대화상자까지 전부 구현돼 있고 인스펙터만 호출을 빠뜨렸다.
CurrentFontOf 가 Italic/Strikeout 을 복사·보존하므로 네 개를 동시에 노출해도 왕복이 안 깨진다.
[행 치수] 인스펙터 전용 지역 스타일로 입력 높이 30→26. 전역 TextBox 를 낮추면 검색칩·대화상자
입력까지 따라 줄어 버튼과 정렬이 깨진다. **ComboBox 는 MinHeight 가 아니라 Height 고정**이라
Height 를 함께 내려야 한다 — 안 그러면 선택 행만 4px 어긋난다(설계 검토에서 잡은 실제 결함).
[라벨] 폭 86→92 + 줄바꿈. 실측상 가장 긴 라벨이 11자("입력 파라미터 컨트롤")라 86px 에서
"액션 대상 컨…" 처럼 잘렸다. 자르면 툴팁을 띄워야 아는 정보가 되므로 접는 쪽을 택했다.
[그 밖에] 태그·마스크 편집기를 여는 "…" 버튼은 26px 칩 옆에서 점 하나로 보여 아이콘 버튼으로.
섹션 헤더 건수를 줄 수가 아니라 **속성 수**로(LeafCount) — 쌍/세그먼트는 한 줄이지만 속성은
여럿이라 그냥 세면 "공통 3"으로 실제보다 적게 나온다.
인스펙터 패널 최소폭 220→250 — 스타일 행이 라벨 92 + 토글 4칸 128 = 220px 를 요구한다.
효과: 공통 5행→3행, 글꼴 4행→3행(속성은 4→6개로 늘었다). TextBox 기준 25행→18행.
'전체 속성 표시' 토글이 기본 창에서 스크롤 없이 보인다(종전에는 한참 아래).
검증: 테스트 124/124, edit-smoke 실패 0, 왕복 1,271건 diff 0/예외 0,
종이 렌더 P062 픽셀 차이 0. 인스펙터를 300px 호스트 창에 담아 양 테마로 렌더해 배치 확인
(dialog-shots 진단에 09/10 추가 — 속성 패널 레이아웃 회귀를 눈으로 잡는 자동 경로).
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
앞 커밋(Window 파생 클래스)과 같은 부류의 결함을 창별 정적 점검이 4건 더 찾아냈다.
전부 "스타일이 없으면 WPF 기본(Aero2) 밝은 크롬으로 떨어지고, 그 위 글자는 앱 전역 암시
TextBlock(B.Ink)을 정상적으로 받아 다크에서 사라진다"는 같은 메커니즘이다.
[ToolTip — 48곳] 가장 넓다. MainView 31곳·InspectorView 15곳·대화상자 6곳 등에서 쓰는데
TargetType="ToolTip" 스타일이 저장소 전역에 하나도 없었다. 다크에서 명암비 1.15~1.17:1.
B.Surface 채움 + B.Line2 테두리 + 라운드 6 으로 앱 룩에 맞췄다.
[ToolBar 자식 키] ToolBar 는 직계 자식에 SetResourceReference 로 자기 키 스타일을 꽂아
암시 Button 스타일을 이긴다 — ToolBar 자체에 스타일을 줘도 그 안 버튼만 Aero2 로 남는다.
ButtonStyleKey/ToggleButtonStyleKey/SeparatorStyleKey/TextBoxStyleKey 를 채웠다(미리보기 창).
[포커스링] Aero2 기본 검정 점선이 다크 버튼 채움(#262626) 위에서 1.39:1 이고, 커스텀 Button
템플릿에 IsKeyboardFocused 트리거도 없어 Tab 위치를 알 단서가 전혀 없었다.
SystemParameters.FocusVisualStyleKey 를 강조색 점선으로 재정의.
[ScrollViewer 코너] 가로·세로 막대가 동시에 뜰 때 노출되는 우하단 사각형은 프레임워크 템플릿
소속이라 우리 스타일이 닿지 않는다. 템플릿이 SystemColors.ControlBrushKey 를 DynamicResource 로
읽으므로 그 키를 토큰 사전에서 덮어썼다 — 테마 전환도 따라간다.
[진단 보강] --dialog-shots 에 암시 스타일 자가 점검을 추가했다. 툴팁·포커스링·툴바 자식·스크롤
코너는 팝업이거나 프레임워크 템플릿 소속이라 **창을 찍어도 스크린샷에 안 잡힌다** — 값으로
확인하지 않으면 회귀를 놓친다. 떼어 놓은 인스턴스는 트리에 붙기 전이라 Style 이 null 이므로
리소스 사전 존재 여부로 판정한다(처음엔 인스턴스 Style 을 봤다가 오판했다).
검증: 자가 점검 8종 전부 정의됨, 테스트 124/124, edit-smoke 실패 0,
왕복 1,271건 diff 0/예외 0, 종이 렌더 P062 픽셀 차이 0.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
사용자 지적: "다른 창으로(새창으로 열리는) 실행하는 항목들도 모두 점검해줘".
[근본 원인 — 대화상자 전체가 다크에서 흰 창이었다]
WPF 는 암시 스타일을 요소의 **정확한 런타임 타입**으로만 찾는다. 저장소의 모든 창이
Window 를 상속한 클래스(SheetOpenDialogView : Window, QueryEditorWindow : Window …)라
DesignerTheme.xaml 의 <Style TargetType="Window"> 는 **단 한 번도 적용된 적이 없다**.
결과: 창 배경이 WPF 기본 흰색으로 남고, 그 위 글자는 암시 TextBlock 스타일(B.Ink #EDEDED)을
정상적으로 받는다 → **다크 테마에서 흰 바탕에 흰 글자**. 쿼리 편집기의 변수 목록처럼 라벨이
통째로 사라지는 화면이 나왔다.
라이트에서 눈에 덜 띈 이유는 우연이다 — 기본 흰색(#FFFFFF)이 의도값 B.AppBg(#F5F5F5)와
거의 같아서다. 그래서 지금까지 라이트만 보고는 발견되지 않았다.
수정: 스타일에 x:Key="ThemedWindow" 를 주고 창 10종에 직접 걸었다(코드로 짓는
ColorPickerWindow 는 생성자에서 TryFindResource). 순수 Window 인스턴스용 암시 스타일은
BasedOn 으로 남겼다. 앞으로 창을 추가할 때 잊지 않도록 스타일 위에 경고 주석을 붙였다.
[신규 진단 --dialog-shots <출력폴더>]
이 결함은 정적 분석으로는 못 잡았다(앞선 감사에서 "대화상자들은 암시 Window 스타일을 그대로
받는다"고 잘못 결론냈다). 실입력 주입은 화면 잠금·세션 격리 상태에서 OS 가 거부한다
(SetCursorPos 무시, SendKeys "Access is denied" — 이번에도 중간부터 막혔다).
그래서 창을 화면 밖(-10000)에 띄워 RenderTargetBitmap 으로 찍는 진단을 만들었다.
창 8종 × 2테마 = 16장을 한 번에 남기고, 각 창의 **해석된 Background 와 Style 적용 여부를
텍스트로 함께 보고**한다 — 픽셀만 보면 원인을 못 가린다(실제로 이 한 줄이 원인을 확정했다).
표본 데이터는 실제 사용 시와 같은 형태로 넣었다(빈 껍데기를 찍으면 의미가 없다).
함정 2개를 코드에 남겼다.
· 기본 ShutdownMode 가 OnLastWindowClose 라 찍고 닫는 순간 앱이 종료된다 → OnExplicitShutdown.
· Window.Content 만 렌더하면 창 배경이 빠져 투명(=PNG 검정)이 되고, 다크처럼 보이는 착시로
라이트 결함이 가려진다 → 배경을 먼저 칠하고 그 위에 콘텐츠를 그린다.
[함께 고친 것 — 여러 줄 입력이 세로 가운데 정렬]
쿼리 편집기의 SQL 이 큰 상자 한가운데 떠 있었다. 템플릿 트리거가 ScrollViewer 의
VerticalContentAlignment 만 Stretch 로 바꿨는데, 텍스트를 배치하는 건 TextBox 자신의
VerticalContentAlignment 이라 아무 효과가 없었다. Style.Triggers 로 옮겨 Top 으로 두고
여러 줄일 때 Padding 도 넉넉히 준다. 인스펙터의 여러 줄 속성 행에도 함께 적용된다.
검증: 창 8종 × 2테마 전부 재렌더해 배경 확인(다크 #0E0E0E / 라이트 #F5F5F5,
미리보기만 의도대로 B.CanvasBg). 테스트 124/124, edit-smoke 실패 0,
**종이 렌더 P062 픽셀 대조 차이 0**.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
사용자 지적: "다크, 화이트 모드 오류나는(대비색상 제대로 표현안됨) 부분 모두 확인해줘".
표면별 병렬 감사 후 지적 74건을 하나씩 반증 검증해 39건 확정(21건 반증). 원인은 셋으로 압축된다.
━━ 1. 전환을 아예 따라가지 못하던 것(라이브 전환 시 옛 색 유지) ━━
[아이콘 브러시 굽기 — 가장 컸다] IconParam.Parse 가 TryFindResource 로 Brush 를 꺼내
LucideIcons.Icon 이 Path.Stroke/Fill 에 대입했다. ThemeManager 는 토큰 사전을 통째로 갈아끼우므로
이미 만들어진 아이콘은 옛 색을 영원히 유지한다. 실측: 라이트 전환 후 플로팅 바의 선택/손 도구가
다크의 B.Ink(#EDEDED)를 붙든 채 흰 배경에 놓여 사라졌다.
→ Parse 가 Brush 대신 **키**를 반환하고 Icon 이 SetResourceReference 로 건다(DynamicResource 등가).
시작 시점에는 LoadSaved 가 MainView 생성보다 앞이라 정상이었고, 전환 시에만 나던 결함이다.
[그림자 3곳] 플로팅 바는 <StaticResource ResourceKey="B.FloatShadow"/> 요소 구문이라 굳었고,
두 플라이아웃은 아예 리터럴 검정(Opacity 0.4)이었다. 라이트 설계값은 0.14 인데 흰 배경에 진한
검정이 그대로 얹혔다. 셋 다 DynamicResource 토큰 참조로.
[콤보 드롭다운 그림자] 같은 이유로 리터럴 #000000/0.22 → 토큰.
━━ 2. 암시 스타일이 없어 WPF 기본 크롬으로 떨어지던 것(다크에서 안 보임) ━━
ListView·ListViewItem·GridViewColumnHeader·ToolBar·ToolBarTray 는 각자 DefaultStyleKey 를 자기
타입으로 재정의해 기존 ListBox 계열 암시 스타일을 물려받지 못한다. 스타일이 없으니 WPF 기본(Aero2)
흰 시스템 크롬으로 떨어지는데 그 안의 글자는 앱 전역 암시 TextBlock(B.Ink)을 받는다 → **다크에서
흰 바탕에 흰 글자**. 피해 화면: DB에서 서식 열기, 서식 수정이력, 미리보기 창 툴바.
→ 5종 암시 스타일 신설. 행 템플릿은 반드시 GridViewRowPresenter 를 쓴다 — ListBoxItem 을
BasedOn 으로 물려주면 ContentPresenter 가 열 구성을 대체해 행이 바인딩 객체의 ToString()
한 줄로 렌더된다(구현 중 실제로 겪었고 스크린샷으로 확인 후 고쳤다).
[캡션 글리프] CaptionBtn 템플릿이 TextElement.Foreground 상속으로 색을 넘기는데, Content 가
<TextBlock> 요소면 그 자신의 암시 스타일(B.Ink)이 상속을 이긴다. B.CaptionFg/FgHover/CloseHover
4개 토큰이 통째로 죽은 코드였다 → ContentPresenter 지역 암시 스타일로 되돌려 잡음.
━━ 3. 값 자체가 라이트에서 기준 미달(토큰 한 곳을 고치면 수십 곳이 낫는다) ━━
B.Muted #969696 → #6B6B6B (라이트만)
양 테마 동일값이라 라이트 배경에서 2.50~2.96:1 — 본문 4.5:1 은 물론 아이콘 3:1 도 미달이었다
(다크는 같은 값이 4.85~6.53:1). 회색 하나를 양 테마가 공유할 수 없다. 사용처 59곳이 전부
Foreground 라 채움 룩에는 영향이 없다. 새 값은 Panel 5.33 / AppBg 4.89 / Canvas 4.51 / Chip 4.63.
B.CanvasBg #ECECEC → #D8D8D8 종이 대비 1.18 → 1.43:1 (다크는 14.35:1인데 라이트만 용지 경계가
사실상 없었다). 용지 자체는 손대지 않았다.
B.InputBorder #E3E3E3 → #C4C4C4 라이트는 Input=Panel=Surface=#FFFFFF 라 이 테두리가 입력란의
유일한 경계인데 1.28:1 이었다.
B.Line2 #D8D8D8 → #C6C6C6 체크박스 상자·컨트롤 경계
B.Sel #DBEEFF → #C2E2FF 선택 채움 1.19:1 → 어느 항목이 선택됐는지 안 보였다
B.Hover #F3F3F3 → #EAEAEA
B.Accent2 #E4F2FF → #CFE7FF 도구 '켜짐' 표시
B.Success #2E9E6B → #217A52 결과 문구 2.94:1 → 4.6:1
B.SliderRing #CFCFCF → #9A9A9A 흰 썸의 유일한 윤곽
신규 토큰 2종(브랜드색 #0099FF 는 그대로 두고 역할만 분리):
B.AccentText — 강조 '글자'. 흰 배경 위 3.0:1 로는 본문이 안 된다 → 라이트 #0F6FBF(5.19), 다크는
어두운 배경이라 브랜드색 그대로(6.09). 쿼리 편집기·마스크 편집기 헤더에 적용.
B.AccentFill — Primary 버튼 채움. 흰 글자를 얹으므로 양 테마 #0F6FBF(5.19). 브랜드색 위 흰 글자는
3.0:1 로 본문 미달이었다.
그 외: MiniToggle Opacity 0.55 → 0.75(옅은 색을 또 반으로 깎아 라이트 2.22:1 이었다 → 3.19/4.02),
스크롤 막대·화살표가 이미 있는 B.ScrollThumb* 토큰을 무시하고 리터럴을 쓰던 것 정리(라이트에서
3:1 미달이고 호버·드래그로 갈수록 더 흐려지는 어포던스 역전이었다), TextBox 포커스 표시를 호버와
같은 B.Line2 에서 B.Accent 로(포커스 위치가 유일한 단서인데 1.3:1 이었다), 메뉴 키보드 하이라이트
B.Hover(1.1:1) → B.Sel, 도구 토글 '켜짐'에 강조 테두리 추가(채움만으로는 1.14~1.19:1),
쿼리 편집기 GridSplitter 를 공용 ColSplitter 스타일로, 색상 피커 견본 테두리를 테마색이 아니라
견본 자신의 명도로 결정(라이트에서 흰 견본·다크에서 검정 견본이 빈칸으로 보였다).
━━ 판정을 바꾸지 않은 것 ━━
용지 렌더 도메인(ControlTemplates/PageView/PrintService/SelectionOverlay/인라인 편집기)의 고정색은
**정상**이다. 레거시 서식을 1:1로 그리는 곳이라 테마가 새어 들어가는 쪽이 버그다. 감사에서 이 경계를
반대로 지적한 건들은 반증 처리했다.
남는 한계: 컨트롤 경계의 WCAG 3:1 을 완전히 채우려면 라이트 테두리를 #949494 까지 올려야 하는데
[200] 의 가벼운 룩을 크게 벗어나 가시성 확보선(#C4C4C4)에서 멈췄다.
검증: 테스트 124/124, edit-smoke 실패 0, 왕복 1,271건 diff 0/예외 0,
**종이 렌더 P062 픽셀 대조 차이 0**(토큰을 여럿 바꿨는데도 용지에 새지 않음을 확인).
실 UI: 다크↔라이트 왕복 전환 후 아이콘 색 추종 확인, 라이트 전 화면·인스펙터·레이어,
DB에서 서식 열기 대화상자를 양 테마에서 열어 열 구성과 가독성 확인.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
사용자 지적: "서식 목록 구분없이 하나로 다 나오는거 불편한데 묶여서 나와야해".
[기준 선정] 후보 컬럼 7종을 실DB로 전수 집계해(신규 --db-cls) ShtClsCod 를 택했다. 이것만이
판정 3기준을 통과한다 — 그룹 24종, 최대 그룹 269건(16%), 빈값 1건(0.06%). 나머지는 전부 한쪽
쏠림이다: ShtTyp D 79% / ShtPatTyp C 92% / ShtLstTyp NONREC 88% / ShtDepCls 빈값 74%.
의미상으로도 ShtClsCod 만 '분류'이고 나머지는 동작 규칙이다(ShtWrtTyp=1장 제약, ShtUsrTyp·
ShtPatTyp=작성 게이트, ShtOprTyp=.NET 클래스명, ShtDspSeq=정렬키).
무엇보다 이건 사용자가 이미 아는 축이다. 레거시에서 서식생성기를 여는 화면(기록지정보 fmShtMst)
상단이 `[ 분류별 전체 ]` + 분류별 탭이고, EMR 기록지 트리 1단 헤더도 같은 값이다. 재교육 비용 0.
[라벨] 코드→한글명은 M_DtlMst 의 DtlTblCod='EMR_SHEET_GROUP' 이 소유한다. 소스 어디에도 매핑표가
없고 병원별 마스터라(HspCod 분기가 레거시에 다수) 하드코딩하면 다른 병원에서 즉시 오답이 된다.
런타임 조인만 쓴다. 실측: 마스터 24행 ↔ 서식 24코드 완전 일치, 고아 코드 0건.
[조인 방향] E_ShtMst 드라이빙 + LEFT JOIN 이다. 레거시 EMR 트리(bizSheetList.vb)는 M_DtlMst 를
드라이빙으로 써서 분류 미지정 서식이 목록에서 통째로 사라지는데, 열기 목록에서 그건 "그 서식을
영영 못 여는" 장애다. 기록지정보 화면 방식(dtShtMst.vb:75-81)을 따랐다.
[조인 증식 방어] M_DtlMst 는 PK 에 DtlStrDte 가 들어가 같은 코드가 기간별 다중행일 수 있다.
그대로 조인하면 서식이 목록에 두 번 뜬다. 실측상 현재 증식 0건이지만 그건 데이터 운이지 쿼리가
막은 게 아니므로, ROW_NUMBER 로 적용일 최신 1행만 택하게 했다.
[그룹 순서] LPAD(DtlDspSeq,4,'0') 우선, 그다음 코드순, 미분류는 맨 뒤. 병원이 정한 표시순서가
1순위여야 레거시 분류 탭과 같은 자리에 뜬다. 이 병원은 DspSeq 가 전부 비어 있어 결과적으로
코드순(A~X)이지만, 채워 쓰는 병원에서 어긋나지 않는다. LPAD 는 레거시의 2자리 대신 4자리 —
'100' 과 '99' 가 뒤집히는 것을 막는다.
[상한 300 → 2,000] 종전 300 상한은 정렬이 분류순으로 바뀌면서 성격이 달라진다. 코드순 300 은
고르게 잘렸지만 분류순 300 은 뒤쪽 분류가 통째로 사라져 "그 분류가 없는 것"처럼 보인다.
모집단 1,629건이라 이제 전량이 온다. 도달 시 하단에 잘렸다고 명시한다(조용한 절단 금지).
[분류명도 검색 대상] 헤더에서 본 '간호기록'을 그대로 쳤는데 0건이면 배신감이 크다.
LIKE 토큰에 DtlCodNam 을 추가했다.
[접기 상태] 기본 접힘 + 검색 시 자동 펼침. 다만 레거시는 서식이 꽉 찬 평면 목록으로 시작하므로
접힌 첫 화면을 낯설어하는 사용자가 있다 — 전체 여닫기 토글을 검색칩 옆에 두고, 그 선택을
%LocalAppData%\SheetMe\prefs.json 에 보존한다(신규 UserPrefs). 매 실행마다 다시 펴지 않아도 된다.
[WPF 함정] 그룹핑을 걸면 WPF 는 기본적으로 가상화를 끈다 — 1,600여 항목이 한꺼번에 실체화돼
탭 전환이 눈에 띄게 멈춘다. IsVirtualizingWhenGrouping + VirtualizationMode=Recycling 필수.
그룹 키를 문자열이 아니라 SheetGroupViewModel 인스턴스로 둔 것도 의도다 — CollectionViewSource 가
값 동등성으로 묶으므로 코드당 같은 인스턴스를 주면(SheetGroupRegistry) 헤더에서
Name.IsExpanded 를 TwoWay 로 바인딩해 접기 상태를 VM 이 소유할 수 있다.
[열기 대화상자] 같은 ListSheets 를 쓰는 SheetOpenDialogView 는 그리드라 트리 대신 '분류' 열을
추가했다(같은 정보, 그 화면에 맞는 형태).
[진단 모드 신설] --db-cls (분류 후보 분포 + 라벨 마스터 + 조인 증식 검사 + 고아 코드 + 모집단),
--db-tables <패턴> (이름 모르는 마스터 테이블 탐색). 둘 다 읽기 전용.
검증: 테스트 124/124, edit-smoke 실패 0, 왕복 1,271건 diff 0/예외 0,
**종이 렌더 P062 픽셀 대조 차이 0**.
실 UI: 24분류 한 화면 · 의사기록 펼침 266건 · '경과기록' 45건/6분류 자동 펼침 ·
'간호기록' 204건/4분류(분류명 매칭 동작 확인).
별건 보고: 목록 모집단에 폐기 서식(ShtUseYon='N')이 452건(28%) 섞여 그룹 건수를 부풀린다.
레거시 서식생성기도 이 필터를 걸지 않으므로 이번엔 동작을 바꾸지 않았다.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
1/2 가 껍데기(스크롤바·창 기본값)를 맞췄다면 이번은 그 안에 든 정보다. [200] 과의 실제 격차는
장식이 아니라 "한 화면에서 얼마나 읽히는가"였고, 그중 둘은 데이터 손상 경로이기도 했다.
[레이어] 행 라벨을 Model.Id → 신규 LayerName(Text 우선, 없으면 Id)으로. 운영 서식의 Id 는
대부분 Label12 꼴 자동 이름이라 291개짜리 목록이 아무것도 알려주지 않았다. Id 는 외부 참조
키이므로(DataTableField 대상명, EventHandlerMappingTag 의 ShtCod_컨트롤명 규약) 버리지 않고
툴팁 + 검색 대상으로 유지한다. 앞에 타입 아이콘(Accent), 뒤 숨김/잠금을 MiniToggle(eye/eye-off,
lock/unlock)로 — 사각 체크박스로는 상태가 안 읽혔다.
검색칩 신설(LayerFilter, 이름·Id 부분일치). 컨트롤 최대 1,067개 서식에서 스크롤만으로는 대상을
찾을 수 없다. 칩 안 TextBox 에 MinHeight=0 필수 — 전역 TextBox MinHeight 30 이 32px 칩에서
글자를 자른다.
[인스펙터·여러 값] IsMixed 가 설정만 되고 화면 어디에도 바인딩돼 있지 않았다. 다중선택에서 값이
갈리면 편집기가 빈 칸이 되는데 그 빈 칸은 "값 없음"과 구별되지 않는다. 무심코 입력하면 서로
달랐던 값이 전부 한 값으로 덮인다. 편집기 위에 비파괴 오버레이("여러 값")를 얹고, 포커스가
들어오면 감춘다 — 값은 LostFocus 에만 올라가므로 타이핑 중 ValueText 는 계속 "" 이고 안내가
방금 친 글자 위에 남는다.
토글 행은 IsOn 을 bool? 로 바꿔 3상태로. false 로 내리면 "전부 꺼짐"으로 보이고, 사용자가 한 번
켰다 끄면 전부 False 가 되어 조용히 뭉개진다.
[인스펙터·접이식] 섹션 헤더를 클릭 가능한 접기/펴기로(셰브런 + 소속 행 수). 고급 속성까지 켜면
수십 행이 된다. Rows 는 평면 컬렉션을 유지하고 섹션이 자기 행을 들고 있다가 컨테이너
Visibility 만 토글한다 — 행 VM 을 재생성하지 않아 편집 중 값·포커스가 보존되고, 다중선택
재구성이 잦은 이 화면에서 트리 재구축 비용도 피한다.
[타이틀바] 로고 메뉴 루트(셰브런) 아래로 5개 메뉴를 흡수하고, 편집 아이콘 스트립(실행취소·다시
실행 | 복사·삭제·그룹·해제)과 우측 액션(테마·저장·미리보기)을 배치. 중앙 제목은 뺐다 — 문서 탭이
같은 정보를 이미 들고 있다.
[빈 상태] 문서 0개 → 회색 허공 대신 다음 행동 3개(새 서식 / DB에서 열기 / 파일 열기).
선택 0개 → 인스펙터에 "선택 없음" 한 줄만 떠 패널이 고장 난 것처럼 보이던 것을 안내로 교체.
[그 외] 서식 목록에 하단 상태 한 줄(로딩·건수·DB 미접속) — 종전에는 목록 위에 "불러오는 중"
오버레이가 덮여 목록이 가려졌고 건수는 창 하단 전역 상태줄에만 있었다. 활성 문서 탭 아이콘을
Accent 로. 플로팅 바의 ±버튼 제거([200] 동일 — Ctrl+휠이 주 조작이고 배율 버튼이 플라이아웃을
겸한다), 대신 "화면 맞춤" 추가. ZoomFit 은 문서 전체가 아니라 활성 페이지 한 장 기준이다
(7페이지 서식을 통째로 맞추면 14% 가 되어 아무것도 안 보인다). 뷰포트 크기는 캔버스 뷰가
SizeChanged 에서 VM 에 통지 — VM 이 시각 트리를 몰라도 되게 한다.
열 기본 폭 232→248 / 280→300.
검증: 테스트 124/124, edit-smoke 실패 0, 왕복 1,271건 diff 0/예외 0.
**종이 렌더 P062 픽셀 대조 차이 0**(4,802바이트 전부 일치).
실 UI 확인: P062 레이어 행·인스펙터 접기, P093 291컨트롤에서 레이어 검색("알러지" → 10건),
116개 선택 시 X/Y/너비/높이 "여러 값" 표시.
남은 배치: E(선택 오버레이) · F(캔버스 크롬 — 종이에 닿는 유일한 배치) · G(토스트·ThemedDialog).
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
[200]SheetMe Studio Designer 와 6개 표면을 대조한 결과, 뼈대는 이미 같았다 —
WindowChrome 파라미터, 5열 골격, 색 토큰 다크·라이트 64개 값 전부, Button/TextBox/Menu/
TabItem/ComboBox/CheckBox 등 스타일이 바이트 단위로 일치한다. 실제 격차는 "껍데기 안에
무엇이 들어가는가" 셋이었다: 스크롤바, 타이틀바 구성, 좌측 패널·인스펙터 정보 밀도.
이번 커밋은 그중 전 화면에 깔려 체감이 가장 큰 스크롤바와 테마 기반을 맞춘다.
[스크롤바] [200] 통일 규격 이식 — 9px 폭(기존 11px), 상하 14px 화살표 버튼, 막대 CornerRadius 5.
막대 색은 토큰이 아니라 리터럴 중립 그레이(#8A8F96/#A6ACB3/#C4CAD0)다 — 다크·라이트 양쪽에서
같은 회색으로 보여야 배경에 따라 튀지 않는다([200] 과 동일한 의도적 선택).
MinThumbTrack.cs 를 [200] src/Ui/SheetMe.Ui 에서 복사 이식(vendoring, 헤더에 동기화 의무 명시).
기본 WPF Track 은 Thumb.MinHeight 를 무시해 항목이 많은 목록에서 막대가 실처럼 가늘어지는데,
기존 ScrollThumb 의 MinHeight 48 이 바로 그래서 먹지 않고 있었다. MinThumbTrack 이 arrange 를
직접 해 최소 길이 44px 를 보장한다.
[창 기본값] Window 암시 스타일에 FontSize 13 + Foreground B.Ink 추가, Background 를
B.Panel → B.AppBg 로. 이게 없어 대화상자 8종이 WPF 기본 12px + 시스템 검정으로 렌더돼
메인 셸(13px)과 결이 어긋나 있었다. 토큰 참조는 DynamicResource 유지 — [200] 의
StaticResource 를 그대로 베끼면 다크에서 값이 굳는다.
[아이콘 색 인자] 컨버터 ConverterParameter 를 "크기" 단독에서 "크기|브러시키" 로 확장.
구분자가 없으면 종전 동작(크기만, B.Muted)을 그대로 유지해 기존 호출부 7곳이 안전하다.
지금까지 B.Muted 가 하드코딩돼 있어 활성 문서 탭 아이콘 강조나 레이어 타입 아이콘 색 구분이
구조적으로 불가능했다. 후속 배치의 선행 조건이다.
팔레트 아이콘 매핑도 정리 — MaskedTextBox 가 TextBox 와 같은 아이콘이라 구분이 안 됐다.
[그 외] DocTabItem 비활성 탭에 하단 테두리를 넣고 활성만 끊어 크롬식 룩으로.
BarToolToggle(플로팅 바 선택/손 — 켜짐을 B.Accent2 로 은은하게) 과 MiniToggle 키 스타일 신설.
암시 ToggleButton 스타일은 인스펙터·레이어 토글도 쓰므로 손대지 않고 키로 분리했다.
B.FloatShadow 토큰 신설 — 플로팅 바 그림자가 고정값 하나라 라이트에서 흰 배경에 검은 그림자가
그대로 얹혀 있었다. 다크 20/3/0.5, 라이트 26/5/0.14 로 분리. 기존 34개 토큰 값은 손대지 않았다.
검증: 테스트 124/124, edit-smoke 실패 0, 왕복 1,271건 diff 0/예외 0.
**종이 렌더 P062 픽셀 대조 차이 0** — 암시 스타일 변경이 용지에 새지 않음을 확인.
x:Key 없는 암시 스타일 개수도 이식 전과 동일(25개)하게 유지했다.
남은 배치: C(인스펙터 접이식 섹션·세그 토글) · D(좌측 패널·타이틀바·빈 상태) ·
E(선택 오버레이) · F(캔버스 크롬 — 종이에 닿는 유일한 배치) · G(토스트·ThemedDialog).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
레거시 MaskedTextBox 는 System.Windows.Forms.MaskedTextBox 를 상속하고 Mask 를 재정의하지 않아
WinForms 기본 마스크 디자이너(프리셋 목록 + 시험 입력)를 그대로 물려받는다. SheetMe 인스펙터는
평범한 텍스트 상자뿐이라 마스크 문법을 외워야 했다.
[프리셋] LegacyMaskCatalog — .NET 문화권 기본값(짧은 날짜/전화번호 등) 대신 운영 활성 디자인
전수 census 에서 실제로 쓰이는 마스크를 사용 건수와 함께 제공한다. 기존 서식과 같은 표기를
그대로 고를 수 있어 표기 흔들림이 줄어든다. 날짜/시간/날짜+시간/번호 그룹 + 표준 3종.
[미리보기] 두 줄로 보여준다.
빈 값 표시 — 마스크만으로 화면에 어떻게 보이는지(ToPromptDisplay)
시험 입력 — 값을 쳐 보면 어떻게 채워지는지(Apply, 신규)
인스펙터 행에도 '빈 값 표시'를 상시 노출해 편집기를 열지 않아도 확인된다.
[Apply 규칙] 구현 중 모호한 경우가 드러나 규칙을 단순하게 확정했다.
1) 리터럴은 항상 그대로 출력되고 입력을 소비하지 않는다.
2) 지시자는 입력에서 자기에게 맞는 첫 문자를 가져오며, 맞지 않는 문자와 공백은 건너뛴다.
값만 치든(20260812) 구분자를 같이 치든(2026-08-12, 2026년 08월 12일) 결과가 같아진다.
공백을 건너뛰는 이유는 '9' 가 공백도 받아들이기 때문이다 — 그대로 두면 구분자 공백이 데이터
자리를 먹어 한 칸씩 밀린다. '9' 의 공백 허용은 빈 자리를 위한 것이고 그건 프롬프트 문자가 이미
표현한다.
한계를 코드에 명시했다: 리터럴이 숫자인 마스크(2\000년 — 운영 460건 중 1건)는 사용자가
리터럴까지 치면 어디까지가 데이터인지 원리적으로 가릴 수 없다. 편집 가능한 자리만 치면 된다.
미리보기용이므로 휴리스틱을 쌓기보다 규칙을 단순하게 두는 편이 낫다고 판단했다.
안내문에서 백슬래시를 글리프로 보여주지 않는다 — 한글 글꼴에서 원화 기호로 렌더되어 오해를 부른다.
검증: 테스트 124/124(마스크 26건 추가 — Apply 자리 채움/구분자 무시/유효성 거부/대소문자 변환자/
이스케이프/공백 처리, 프리셋 형식·중복·운영 다빈도 포함 검사). edit-smoke 실패 0,
왕복 1,271건 diff 0/예외 0. 실제 UI 로 대화상자 열어 프리셋 그룹·건수·두 줄 미리보기 확인.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
4단계 육안 대조에서 발견한 차이. 마스크 패턴을 그대로 그려서 값이 이미 입력된 것처럼 보였다.
레거시 ___년 __월 __일 (프롬프트 문자)
SheetMe 0000년 90월 90일 (마스크 패턴 원문)
저장 데이터와는 무관한 표시 문제지만, 운영 실사용이 MaskedTextBox 475개 / Mask 보유 460개라
자주 눈에 띈다.
LegacyMask.ToPromptDisplay 신설(Core, 순수 함수) — 입력 자리 지시자는 프롬프트 문자로,
리터럴은 그대로, 이스케이프(\)는 다음 문자를 리터럴로, 대소문자 변환자(< > |)는 표시하지 않는다.
활성 디자인 전수 census 로 지원 범위를 정했다: 등장 지시자는 0(2,675) 9(687) #(12) 와
이스케이프 \(2) 뿐이고 문자류(L ? & C A a)와 변환자는 쓰이지 않는다. PromptChar 속성을 가진
컨트롤은 0개라 전부 기본값 '_' 이지만, 속성이 있으면 반영하도록 구현했다.
구분자 . , : / $ 는 WinForms 가 문화권 구분자로 치환하지만 원문 그대로 둔다 —
운영 마스크는 보이는 대로 쓰이고 레거시 화면도 그렇게 나온다.
MaskDisplay 는 표시 전용 파생 속성이라 PropBag 에 아무것도 쓰지 않는다(왕복 무손실 유지).
인스펙터 편집용 Mask 원문 속성은 그대로 둔다.
검증: 테스트 98/98(마스크 28건 추가 — 운영 실사용 마스크 10종, 이스케이프, 문자류 지시자,
변환자, PromptChar 지정, 끝 백슬래시, 표시 길이 보존). S481 재렌더로 레거시와 동일 표시 확인.
edit-smoke 실패 0, 왕복 1,271건 diff 0/예외 0.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
[전 페이지 렌더] --db-render 가 첫 페이지만 뽑던 것을 전 페이지로 확장했다(이름_p1.png …).
운영 서식 128건이 다중 페이지라 첫 장만 봐서는 검증이 되지 않는다.
[E_SctMst 어서션 정정] --db-save-smoke 가 제자리 갱신 경로에서도 sctCount == walkerCount 를
요구하고 있었는데, 이 불변식은 버저닝 경로에만 성립한다.
제자리 갱신은 레거시와 동일하게 삭제된 컨트롤의 행을 지우지 않으므로(SctKey 안정성) 과거에
지워진 컨트롤의 고아 행이 누적된다. P347 에서 실제로 걸렸다 — E_SctMst 3행(MDesignerHost1,
TextBox1, TextBox2) 대 현재 XML 2개(MDesignerHost1, TextBox2)로, TextBox1 은 과거에 지워진
컨트롤의 잔존 행이었다. 기존 데이터이지 저장 경로의 결함이 아니다.
제자리 갱신은 "워커 산출 행이 전부 존재"(sctCount >= walkerCount)로 바꾸고 잔존 행 수를
리포트에 표시한다. 버저닝은 전량 재생성이므로 정확히 일치 요구를 유지한다.
[검증 준비 결과] 위험도 상위 16건을 SheetMe 로 재저장하고 저장 전/후 렌더를 픽셀 비교했다 —
39페이지 전부 동일(J209 6장, S256 7장 포함). 대상 선정은 운영 활성 디자인을 전수 스캔해
위험 요소로 가중치를 매겼다: 자리표시 15건 · Spread 7건 · Binary 569건(45%) ·
배경이미지 315건 · 다중페이지 128건 · 제자리갱신 36건.
레거시 뷰어 대조는 사용자가 직접 수행한다(공용 ServerInfo ini 의 CurrentServer 를 운영에서
테스트로 바꿔야 하는데, 그 파일을 레거시 EXE 187개가 공유하므로 자동화하지 않았다).
바탕화면 'SheetMe-검증패키지' 에 체크리스트·렌더 PNG 39장·자동검증 리포트를 준비했다.
검증: 테스트 70/70, edit-smoke 실패 0, 왕복 1,271건 diff 0/예외 0,
db-save-smoke 제자리 갱신(P163 정상/P347 잔존 1건 정상)·버저닝(S999) 양쪽 통과.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
[자격증명] 접속 문자열 우선순위를 환경변수 > appsettings.Development.json > appsettings.json >
ServerInfo ini 로 세웠다. 병원 설치본은 설정 없이 [000]bin 의 MSYSTECH_ServerInfo.ini 를
자동으로 찾아 붙는다(레거시와 동일한 MD5 → 3DES-ECB 복호).
- ServerInfoReader 를 [200]SheetMe 에서 복사 이식(vendoring — 두 저장소가 분리돼 있어
ProjectReference 불가, 레거시 암복호 규약은 변할 이유가 없는 고정 자산). 헤더에 동기화 의무 명시.
- 실측 검증: ini 566라인/병원 엔트리 76개, CurrentServer=029.BSGH 복호 후 실제 접속 성공.
- SaveMode 기본값을 File 로 되돌렸다. ini 의 CurrentServer 가 운영 병원 DB 를 가리키므로
DB 쓰기는 명시적으로 켠 단말에서만 활성화되어야 한다.
- ConfigService 로 단일 소유화 — ConfigLoader.Load() 11회 호출이 ini 파싱 + 3DES 복호를
매번 반복하던 것을 1회로. 죽어 있던 Designer:GridSize/SnapThreshold 를 SnapEngine 에 배선하고,
쓰이지 않던 His:Provider 와 DefaultPaperWidth/Height 는 제거했다(후자는 설정이 아니라
레거시 패리티 상수다).
[산출물] 8.6MB/34파일 → 7.2MB/26파일.
- EmrDataContext 제거 — M.Framework.DBAccess/TableFramework/M.MW.Data.EMR 의 유일한 소비처였는데
그 클래스가 어디서도 인스턴스화되지 않았다. DB 접근은 전부 raw Oracle 클라이언트를 쓴다.
- Microsoft.Web.WebView2 는 ExcludeAssets="runtime" — M.Framework.WPF 전이 의존일 뿐
소스 참조 0건. [000]bin 과의 이름 충돌 3건도 함께 사라진다.
- Production.pubxml(win-x64, FDD, SatelliteResourceLanguages=ko). RID 를 csproj 가 아니라
pubxml 에 둔 이유는 csproj 에 넣으면 dotnet build/test 까지 RID 별 복원을 타기 때문이다.
- tools/publish.ps1 — 비밀값 하드 게이트 + [000]bin 충돌 경고 + SHA256 매니페스트 + zip.
게이트는 역방향으로 검증했다(appsettings.json 에 실접속 정보를 넣고 실행 → 정상 차단).
- nuget.config 신설 — 사내 피드가 개발자 개인 OneDrive 경로라 다른 머신에서 복원이 불가능했다.
%MSYS_NUGET_FEED% 환경변수로 받게 해 최소한 실패 원인이 드러나게 했다.
[배포 규약] docs/DEPLOYMENT.md.
[000]bin 최상위 평면 복사를 금지한다 — 실측 결과 Oracle.ManagedDataAccess.dll 이 겹치고
(신규 .NET Core 5,434KB ↔ 기존 .NET FW 4,602KB), 덮으면 그 폴더의 레거시 EXE 187개가
전부 Oracle 접속 불능이 된다. [000]bin\SheetMe\ 하위 폴더에 둔다 — Information/Log/
OCR서식생성기/SpreadDesign 등 기존 앱들과 같은 방식이다.
FDD 로 배포한다: [000]bin\OCR서식생성기 가 이미 net10.0 + WindowsDesktop.App 10.0.0 을
요구하며 운영 중이라 런타임 존재가 확인된다. 없는 단말이 나오면 -SelfContained 한 번이면 된다.
[로깅] AppLog — LogManager 배선. 모든 호출을 try/catch 로 감싸 로깅 실패가 업무를 막지 않게 했다.
- Redact 필수 — 접속 문자열을 값으로 들고 다니므로 예외 메시지에 자격증명이 섞일 수 있다.
기록 직전 1회 통과시킨다.
- LogLevel 은 열거형이 아니라 문자열 속성이라 오타를 컴파일러가 못 잡고, 잘못된 값이면
FIXED 만 남고 나머지가 조용히 사라진다. LogType 열거값의 이름으로만 지정하게 했다.
- 문서에 있는 HandleShutdown 은 6.0.0 DLL 에 실제로는 없어(XML 문서가 앞서 있음) 쓰지 않는다.
대신 기록이 비동기 배치라 스모크에서 짧게 폴링해 확인한다.
- 로그 경로는 실행 폴더\logs\Designer, 쓰기 불가 시 %LocalAppData% 폴백(쓰기 프로브까지 확인).
[전역 예외] Dispatcher/AppDomain/TaskScheduler 3종을 진단 분기보다 앞에 등록했다.
UI 예외는 기록 후 계속 진행한다(편집 중 문서를 예외 하나로 잃지 않게) — 단 10초 내 5회면
무한 팝업 루프이므로 강제 종료한다.
DialogService.ShowError 도입 — 우리가 던진 안내성 예외는 메시지를 그대로 보여주고, 그 외는
일반화 문구 + 오류 코드만 노출한다(코드가 로그 줄머리와 같아 전화 한 통으로 특정된다).
ex.ToString() 전문을 그대로 띄우던 2곳을 정리했다.
로그인/권한거부/DB저장은 감사 이벤트(FIXED)로 남긴다.
검증: 테스트 70/70, edit-smoke 실패 0(마스킹 5건 + 로그 배선 1건 추가), 왕복 1,271건
diff 0/예외 0, db-save-smoke 제자리 갱신 통과. publish.ps1 정방향/역방향 모두 확인.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
[사용자 컨텍스트] 감사 컬럼에 Windows 계정명이 들어가던 6지점을 HIS UidCod 로 교체했다.
레거시 이력 화면(ucSheetHistory.vb:51)이 SdgUidCod 를 수정자로 표시하므로 지금까지는
감사 추적이 끊겨 있었다.
- UserContextStore — M_UidMst 단독 조회로 인증 판정(레거시 bzLogIn 자동로그인과 동일 의미론,
비밀번호를 보지 않고 행 존재 + 유효기간만 확인). 부서/병원은 LEFT JOIN best-effort 표시용.
적용일자는 서버 시각을 쓴다 — 단말 시계가 밀리면 유효 계정이 거부될 수 있다.
- UserSession — 정적 set-once. 생성자 주입을 택하지 않은 이유는 진단 모드(--db-*)가
ViewModel 그래프 밖이라 주입으로는 감사 기록 지점에 닿을 수 없기 때문이다. 대신 읽는 곳을
App 초기화 / FormDesignDataBusiness / MainViewModel 표시 세 군데로 제한했고, 저장소는
지금처럼 uid 를 파라미터로만 받는다. 진단 모드는 "SMOKE" 식별자를 쓴다.
- StartupArguments — 레거시 규약 "UidCod,ComNum,ShtCod" 관용 파싱(PadRight 후 split) 이식.
ComNum 은 레거시에서도 선언·대입뿐인 죽은 인자라 보존만 하고 쓰지 않는다.
- App.OnStartup 재구성 — 진단 플래그 12종을 RunDiagnostic 으로 분리(각 분기가 종료 코드를
반환하도록 정리), 세션 확정 후 MainView 표시. 기동 인자에 ShtCod 가 있으면 PendingSheetCode
로 넘겨 빈 새 문서를 만들지 않고 그 서식만 연다(열기 실패 시에는 빈 문서로 폴백).
- 인자로 받은 UidCod 조회에 실패하면 기동을 중단한다(레거시 :892 와 동형). 인자 없이 단독
실행하면 미인증으로 계속하되 DB 쓰기를 차단하고, 개발 편의는 His:DevUidCod 설정으로 뺐다.
[권한 게이트] E_ShtMst.ShtUsrDesYon — 'Y' 또는 미설정이면 허용, 그 외만 거부(레거시
frmSheetDesigner.vb:71-84 규칙, 거부 문구도 그대로). 레거시는 게이트가 열기 진입점마다
흩어져 있어 이력 노드 클릭·MessageQueue 두 경로로 우회가 가능했는데, SheetMe 는 모든 열기가
OpenDbSheet 로 수렴하므로 거기 한 곳이면 구조적으로 우회가 불가능하다. 추가로 이력 열람과
DB 저장에도 넣었다 — 후자는 레거시에 아예 없던 구멍이다(열기 이후 플래그가 꺼질 수 있고,
파일에서 연 문서를 DB 에 저장하는 경로는 열기 게이트를 거치지 않는다).
목록에서 거르지 않고 잠금 아이콘으로 표시한다(안 보이면 사용자가 진단할 수 없다).
--db-gate 진단 신설 — 실측 결과 차단되는 디자인 보유 서식 0건(N 은 1건뿐이고 디자인 없음).
[서버 시각] ServerClock 신설(SELECT SYSTIMESTAMP 왕복 1회로 8/12/17자리 확보, 포맷은
invariant 로 C# 에서 — NLS 의존 제거). 감사 정확성뿐 아니라 ResolveActiveSdgKey 가
ORDER BY SdgUpdDtm 으로 활성 버전을 고르기 때문에, 시계가 뒤로 밀린 단말이 저장하면
활성 버전 판정이 뒤집힐 수 있었다. Reorder 는 루프 밖 1회로 바꿔 한 번의 순서 변경이
행마다 다른 시각을 남기지 않게 했다. 실패 시 폴백하지 않는다.
RecordWordStore.Remove 에 auditUid 인자 추가 — 남길 컬럼은 없지만(런타임 조회 쿼리에
ShtStt 필터가 없어 논리 삭제로 바꾸면 지운 문구가 계속 노출된다) "모든 쓰기 API 는 uid 를
명시한다"는 규약을 컴파일 타임에 강제한다. 상용구 대화상자는 uid 를 생성자로 받고,
미인증이면 읽기 전용으로 연다.
검증: 사전 실측으로 M_UidMst/M_DepMst/M_HspMst 접근 확인(2단계 최대 리스크 해소, 유효
사용자 539명). 테스트 70/70, edit-smoke 실패 0(기동 인자 파싱 8건 추가), 왕복 1,271건
diff 0/예외 0, db-save-smoke 제자리 갱신 통과, db-word-smoke 통과.
DB 직접 확인: P163 저장분 SdgUidCod='SMOKE', SdgUpdDtm 이 서버 시각과 일치
(변경 전 저장분 S999 는 'Msystech' 로 대비됨).
남은 항목: 단일 인스턴스 + Named Pipe IPC 는 미구현. 기동 인자·게이트가 먼저 필요했고,
바로가기 운영에서는 중복 실행 억제가 덜 급하다. 다중 사용자 병행 운영 전에 처리한다.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
메뉴에는 Ctrl+N/O/S/P 가 표시되지만 Window.InputBindings 가 아예 없어 실제로는 동작하지
않는 잘못된 어포던스였다. 그리고 MainView 에 Closing 핸들러가 없어 타이틀바 X·Alt+F4·
파일 종료 어느 경로로도 미저장 확인이 뜨지 않아, 미저장 다중 문서가 확인 없이 버려졌다.
- UndoService 에 savedDepth 기반 IsDirty/MarkSaved 추가. 기존 CanUndo 는 저장해도 영구
더티라 종료마다 무의미한 확인이 떠서 가드가 존재 가치를 잃는다. Undo/Redo 로 저장 지점
깊이에 되돌아오면 자동 clean 이 되고, 저장 지점보다 얕은 상태에서 새 편집이 들어오면
(분기) 되돌아갈 수 없으므로 savedDepth=-1 로 영구 더티 처리한다. 용량 트림 시에도 보정.
- OnSaveFile/OnSaveToDb 를 bool 반환으로. 기존 void 는 저장 대화상자 취소를 감지할 수 없어
종료 가드에서 저장 선택지를 줄 수 없었다. 성공 시 MarkSaved() 호출.
- ConfirmCloseAll() — 문서별 YesNoCancel 확인. 확인 직전 해당 문서를 활성 탭으로 올려
어느 문서를 묻는지 보이게 한다(저장 경로가 CurrentDesigner 대상이라 전환이 필수다).
과거 버전 열람 탭은 저장하면 활성 디자인을 과거 내용으로 덮으므로 저장 선택지를 주지 않는다.
- 종료 경로 두 개를 모두 막는다: MainView.Closing 에서 e.Cancel, 그리고 파일 종료의
Application.Shutdown() 은 e.Cancel 을 무시하므로 VM 이 먼저 확인하고 IsShuttingDown 을
세워 Closing 이 재질문하지 않게 한다.
- Window.InputBindings 에는 파일/앱 계열만(Ctrl+N/O/S/Shift+S/P/W). 편집 계열은
CanvasKeyboardBehavior 가 계속 담당한다 — Ctrl+Z/C/V/X/A/D/G 를 창에 올리면 인스펙터·
검색 상자 TextBox 의 표준 편집 키와 충돌하고 Spread 붙여넣기 가드도 우회할 여지가 생긴다.
- 저장 진입부에서 포커스가 머문 TextBox 의 BindingExpression.UpdateSource() 호출.
인스펙터 바인딩이 UpdateSourceTrigger=LostFocus 라 값을 고치다 바로 Ctrl+S 를 누르면
커밋되지 않은 채 저장되어 조용히 유실됐다.
검증: edit-smoke 에 더티 추적 8건 추가(초기 clean / 편집 후 dirty / MarkSaved 후 clean /
저장 지점 Undo 복귀 시 clean / 더 되돌리면 dirty / Redo 복귀 시 clean / 분기 후 영구 dirty).
1단계 전체 회귀 통과 — 테스트 70/70, edit-smoke 실패 0, 왕복 1,271건 diff 0/예외 0,
db-save-smoke 제자리 갱신(P163)·버저닝(S999) 양쪽, db-word-smoke 통과.
주의: 키바인딩과 종료 대화상자의 실 UI 조작 확인은 아직 하지 않았다(무인 검증 불가 구간).
병행 운영 전 수동 확인 항목으로 남긴다.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Spread 격자는 E_SpdMst 에서 SpdShtCod+SpdName 으로만 조회되는데 SheetMe 에는 그 행을
만드는 경로가 없다(SELECT 3곳뿐). 이름이 바뀌거나 복제본이 생기면 조회 0건 →
Spread.vb:922-955 가 0바이트 파일을 만들고 FpSpread.Open 이 예외 →
bzDesignSheetLoader.LoadObject 에는 try/catch 가 없어 재귀를 타고 :425-427 로 전파되어
Return Nothing. 즉 표가 사라지는 수준이 아니라 그 서식이 아예 열리지 않는다.
- 이름 변경: 완전 금지(RenameControl 진입부 가드). 기존 중복 이름 거부 경로도 Rebuild() 를
호출하도록 통일 — 지금까지는 return 만 해서 편집 상자에 잘못된 이름이 남아 있었다.
- 붙여넣기: 표만 제외하고 나머지는 통과 + 제외 건수 알림. 전체 거부는 라벨 20개에 표 1개가
섞인 복사 같은 정상 작업까지 막는다. 조기 반환을 Undo.Snapshot() 앞에 둬 빈 스텝이
쌓이지 않게 했다.
- 가드는 커맨드 람다가 아니라 Paste() 메서드 본문에 — CanvasKeyboardBehavior 가 커맨드를
우회해 designer.CopySelection()/Paste() 를 직접 호출하므로 키보드 경로도 함께 막힌다.
- 잘라내기는 별도 가드 불필요(표 삭제 자체는 안전), 복제는 Copy+Paste 라 자동 차단.
- 판정은 VM 형이 아니라 모델 Type 문자열 + 자식 재귀 — 클립보드가 ControlElement 이고
컨테이너에 중첩된 표도 잡아야 한다.
검증: edit-smoke 에 판정·필터 순수 로직 8건 추가(FilterPastable 을 알림과 분리해 무인
검증이 가능하도록 함). 실 UI 프로브로 P062(Spread 실물 보유)를 열어 실제 입력으로 이름
변경을 시도 → 가드 대화상자 발동, 레이어·인스펙터 헤더 모두 Spread1 유지, 확인 후 편집
상자 원복까지 확인.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
레거시 런타임 bzDesignSheetLoader.ConvertQuery 는 토큰 접두어를
PatientInfo/SheetInfo/WorkInfo 의 GetType.FullName 과 Select Case 완전일치로 비교한다.
기존 목록은 <<PatientInfo.ChtNum>> 축약형이라 어떤 토큰도 치환되지 않고 <<...>> 가
SQL 에 리터럴로 남아 ORA 구문오류를 냈다 — 그 데이터소스를 참조하는 컨트롤이 전부 공백이 되는
조용한 실패다. 기존 서식의 쿼리는 원문 보존이라 영향 없고, 신규 작성분만 해당된다.
접두어는 ucLoadSheetBase.vb:7619-7631 이 형을 고정한다:
PatientInfo/WorkInfo → M.CMM.HISOperatingInfo.*
SheetInfo → M.EMR.SheetLoadOperatingInfo.bzSheetInfo
기존 20개 중 8개는 접두어뿐 아니라 속성명 자체가 실존하지 않았다(리플렉션 null):
PatientInfo.OdrNum/OdrSeq, SheetInfo.EmrGbn/PatTyp/OdrNum/OdrSeq, WorkInfo.WrkNam/AdpDep.
bzPatientInfo/bzSheetInfo/bzWorkInfo 의 실존 공개 스칼라 속성으로 다시 큐레이션했다.
DataRow 접근형(...PatInfDR.item(컬럼))은 LastIndexOf(.) 규칙이 접두어를 깨뜨려
이 경로에서 반드시 실패하므로 목록에서 제외.
- LegacyQueryVariableCatalog 신설(그룹/설명 포함 레코드)
- 편집기 목록을 그룹 헤더 + 설명/토큰 2줄 + 툴팁으로 — 축약 오해 재발 방지
- 상용구 편집의 QueryEditorWindow 재사용에 showVariables:false 추가
(상용구 문구 화면에 SQL 변수 목록이 노출되던 오조작 여지 제거)
검증: 테스트 70/70(레거시 완전일치 규칙 시뮬레이션 + 운영 실사용 토큰 존재 검사 포함),
edit-smoke 에 두 모드 실제 창 생성 검사 추가 — 실패 0.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
[값 삭제 버그] InspectorView 의 ComboBox SelectedItem 은 TwoWay 기본이라, 저장된 값이
Choices 에 없으면 null 로 코어스한 뒤 그 null 을 소스에 되써서 속성을 삭제했다.
Label TextAlign Choices 에 Bottom* 3값이 빠져 있어, 해당 라벨을 선택하는 것만으로
발동했다(운영 활성 디자인 1,305건 census: BottomCenter 126 / BottomLeft 107 / BottomRight 16).
- ContentAlignment 9값 전체로 보정
- ChoiceRowViewModel.EnsureChoice — 목록 밖 값을 표시 전에 편입(사이트 커스텀 값 방어)
[기본값] PropertyDef.Default 추가. RowBinding.Get 한 곳에서만 주입하므로
Initialize(building=true) 경로를 타 Commit 이 발생하지 않는다 — 사용자가 건드리기 전까지
PropBag 에 아무것도 쓰이지 않아 왕복 무손실이 유지된다. 다중 선택 병합도 자동으로 옳아진다.
지정 대상(레거시 필드 초기값/WinForms DefaultValueAttribute 근거):
TextBox.RwdRsvWrdYon=True (TextBox.vb:63, 2022-09-28 추가라 이전 서식엔 키 없음)
DataTable.ExcuteQuery=True (MDataTable.vb:15)
Label.TextAlign=TopLeft / TextBox.TextAlign=Left / TextBox.BorderStyle=Fixed3D
Panel.BorderStyle=None / PictureBox.SizeMode=Normal / DateTimePicker.Format=Long
이게 없으면 기본 True 속성이 항상 '꺼짐'으로 보이고, 사용자가 껐다 켜는 순간
명시 False 가 기록돼 상용구 입력·데이터소스 자동 실행이 실제로 죽는다.
ControlRegistryTests 신설 — Default⊆Choices, Toggle 기본값 표기, 그리고 운영 실사용
열거값이 Choices 에 전부 포함되는지(census 근거) 검사.
검증: 테스트 59/59, edit-smoke 실패 0, 전수 왕복 1,271건 diff 0/예외 0.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
ShtCneYon='Y' 서식은 기존 이름 행을 continue 로 건너뛰기만 해서, 라벨 문구를
고쳐도 항목사전(SctObjTxt)이 옛 문구로 남았다. 이 값은 외부인터페이스·환자전달사항·
알람이 리터럴로 매칭하는 조회 키라 조용한 데이터 불일치가 된다.
레거시 bzSaveSheetDesignNControlInfo.vb:378 과 동일하게 SctObjTxt 만 덮어쓴다:
- SctObjTyp/SctParObj/SctObjSeq/SctObjID 는 갱신하지 않음(SctKey 보존 의미론)
- 삭제된 컨트롤의 잔존 행도 지우지 않음(레거시 동일)
- SctKey 기준 UPDATE — 동명 중복 오염 데이터에서 다중 행 덮어쓰기 방지
- Spread AsTemplate 항목 행은 ObjName 이 항목 텍스트라 컨트롤 이름과 분리해 매칭.
레거시의 매 저장 중복 INSERT 버그는 복제하지 않는다(런타임이 개수를 세지 않음).
--db-save-smoke 에 문구 변경 검증 추가 — 무변경 재저장으로는 이 경로를 증명할 수
없으므로 실제로 Text 를 바꿔 SctObjTxt 반영·SctKey 보존을 확인하고 원복한다.
검증: P163(ShtCneYon=Y) 2회 연속 통과(행 6/6 안정, SctKey 보존, 원복 확인),
S999(버저닝) diff 0 + 이전 버전 SdgDelYon='Y', 전수 왕복 1,271건 diff 0/예외 0.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
레거시 서식생성기(VB.NET WinForms) 대체용 C#/.NET 10 WPF 디자이너.
기준선: 실DB 활성 디자인 1,271건 왕복 의미론 diff 0 / 예외 0, 단위 테스트 49/49.
이 커밋에 함께 포함된 자격증명 분리:
- appsettings.json 을 __HOST__/__PASSWORD__ 플레이스홀더로 전환
- 실접속 정보는 appsettings.Development.json 으로 분리(.gitignore 제외,
csproj Debug 조건부 복사라 Release 산출물에 실리지 않음)
- ConfigLoader 를 환경변수 > Development > appsettings 순 레이어링으로 변경,
미치환 플레이스홀더는 '미설정'으로 간주
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>