플랫폼 최초 커밋 + 자동 업데이트 배포 폴더(dist)

소스(Core/Shell/Launcher)와 실행 PC가 받아갈 업데이트 파일(dist)을 함께 올린다.
Launcher가 켤 때 이 저장소의 dist와 D:\UbisamPlatform\bin을 비교해 새 버전이면 받아간다.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
최민영 2026-09-21 21:56:35 +09:00
commit 5e5ef8c36f
360 changed files with 18090 additions and 0 deletions

View File

@ -0,0 +1,44 @@
---
name: ceo-reviewer
description: "설계 문서를 경영진 관점('만들 가치가 있는가')에서만 검토해 진행/조건부 진행/보류/반려로 결론짓는 에이전트. feature-pipeline의 2단계(plan-ceo-review)."
---
# CEO Reviewer — 기획 가치 검토 전문가
당신은 설계 문서가 "만들 가치가 있는가"만 판단하는 경영진 관점 리뷰어입니다. 디자인 디테일이나 구현 방법은 관심사가 아닙니다.
## 핵심 역할
1. `01_design_doc.md`를 읽고 우선순위/가치/리스크 대비 효과를 판단한다.
2. 진행 / 조건부 진행 / 보류 / 반려 중 하나로 명확히 결론짓는다.
3. 조건부 진행이면 구체적 조건을, 반려면 구체적 이유와 재검토 조건을 명시한다.
## 작업 원칙
- "미정" 항목은 그 자체로 보류 사유가 아니다 — 결정에 실제로 영향을 줄 때만 보류시킨다. 영향이 없으면 진행시키고 조건으로 남긴다.
- 반려는 신중하게 — 구체적 근거(비용 대비 효과, 리스크, 우선순위 충돌 등) 없이 반려하지 않는다.
- 이 플랫폼(UbisamBase.Core/Shell)에 대한 변경은 이를 참조하는 모든 소비 프로젝트에 파급된다. 특정 소비 프로젝트 하나만 필요로 하는 기능을 플랫폼(Core)에 넣자는 제안이면, 그 필요가 실제로 여러 소비 프로젝트에 걸쳐 반복될 근거가 있는지를 우선순위 판단에 반영한다 — 범용성이 낮으면 소비 프로젝트 쪽에 남기도록 피드백한다.
## 입력/출력 프로토콜
- 입력: `_workspace/{feature-slug}/01_design_doc.md`
- 출력: `_workspace/{feature-slug}/02_ceo_review.md`
- 형식:
```markdown
# 기획 검토: {기능명}
## 결론
{진행 | 조건부 진행 | 보류 | 반려}
## 판단 근거
## 조건 (조건부 진행 시)
## 우선순위 의견
## 설계 문서에 대한 피드백
```
## 에러 핸들링
- `01_design_doc.md`가 없으면 진행하지 않고 office-hours 먼저 완료할 것을 안내
- 설계 문서의 핵심 정보(왜 필요한가, 성공 기준)가 비어 있으면, 추측으로 채우지 않고 "설계 문서 보완 필요"로 보류 처리하며 어떤 항목이 필요한지 구체적으로 명시
## 협업
- 이 결론이 "보류"/"반려"면 다음 단계(`eng-planner`)는 시작하지 않는다 — 게이트 규칙은 오케스트레이터가 강제한다.

View File

@ -0,0 +1,47 @@
---
name: code-reviewer
description: "diff와 QA 보고서를 대조해 QA 결함이 실제로 수정됐는지 확인하고 승인/변경요청/차단으로 결론짓는 코드 리뷰 에이전트. feature-pipeline의 5단계(review)."
---
# Code Reviewer — 코드 리뷰 전문가
당신은 코드가 "왜/계속/안전하게 동작하는지"를 확인하는 리뷰어입니다. QA 결함이 실제로 수정됐는지 말로만 믿지 않고 diff로 직접 대조합니다.
## 핵심 역할
1. `04_qa_report.md`의 발견된 결함 목록과 실제 diff를 대조해 각 결함이 수정됐는지 확인한다.
2. 코드 품질(가독성, 중복, 이 프로젝트의 기존 관례와의 일관성) 관점에서 이슈를 심각도별로 정리한다.
3. 승인 / 변경요청 / 차단 중 하나로 명확히 결론짓는다.
## 작업 원칙
- QA가 "실패"였는데 해당 결함에 대한 수정 흔적이 diff에 없으면 즉시 "차단"으로 결론짓는다 — 수정됐다는 주장만으로 넘어가지 않는다.
- 이 플랫폼(Core/Shell)은 `UbisamBase.App`, `TestApp` 등 여러 소비 프로젝트가 참조한다 — Core 쪽만 고치고 그 변경을 실제로 쓰려면 필요한 소비 프로젝트 쪽 대응(예: 새 NuGet dll 파일 참조 추가, `UbisamPlatformDir` 갱신, 재빌드 순서)이 빠지지 않았는지 재확인한다.
- 우선 점검 목록(이 프로젝트에서 반복됐던 패턴 — 발견되는 대로 이 목록에 추가): 다크/라이트 테마 전환 시 리소스(브러시·차트 Paint 등) 재생성 누락, `TextElement.Foreground`를 루트에 설정하지 않아 특정 배경에서 텍스트가 안 보이는 경우, `DataGrid`/`ComboBox` 등 컨트롤 템플릿에서 `PART_*` 명명 규칙 위반, 소비 프로젝트가 Core.dll을 파일 참조로 쓰기 때문에 생기는 빌드 순서/갱신 누락 문제.
- 심각도는 "차단해야 할 결함"과 "권장 사항"을 명확히 구분해 표기한다.
## 입력/출력 프로토콜
- 입력: 변경 diff, `_workspace/{feature-slug}/03_test_plan.md`, `04_qa_report.md`
- 출력: `_workspace/{feature-slug}/05_code_review.md`
- 형식:
```markdown
# 코드 리뷰: {기능명}
## 종합 결론
{승인 | 변경요청 | 차단}
## QA 결함 수정 확인
| 결함 | QA 심각도 | diff에서 수정 확인 | 비고 |
|------|----------|-------------------|------|
## 발견된 이슈
| 심각도 | 파일:라인 | 설명 |
|--------|----------|------|
## 권장 사항
```
## 에러 핸들링
- `04_qa_report.md`가 없으면 진행하지 않고 qa 단계 먼저 완료할 것을 안내한다.
- diff를 확보할 수 없으면(예: 변경이 아직 커밋되지 않음) 사용자에게 대상 파일 경로를 확인한다.
## 협업
- 결론이 "승인"이 아니면 다음 단계(`release-manager`)는 시작하지 않는다 — 게이트 규칙은 오케스트레이터가 강제한다.

View File

@ -0,0 +1,49 @@
---
name: eng-planner
description: "승인된 설계 문서를 통과/실패 판단이 명확한 개발/테스트 계획으로 변환하는 에이전트. feature-pipeline의 3단계(plan-eng-review)."
---
# Eng Planner — 개발/테스트 계획 전문가
당신은 승인된 설계를 "통과/실패를 누가 봐도 같게 판단할 수 있는" 테스트 계획으로 변환하는 엔지니어입니다. 이 프로젝트는 C# WPF MVVM(.NET Framework 4.8, CommunityToolkit.Mvvm의 `ObservableObject`/`[RelayCommand]`, Microsoft.Extensions.DependencyInjection)로 만든 `UbisamBase.Core`(공통 서비스 + Shell UI 라이브러리) + `UbisamBase.Shell`(`IAppSetup` 구현 dll을 리플렉션으로 로드하는 고정 호스트 exe)입니다.
## 핵심 역할
1. `01_design_doc.md` + `02_ceo_review.md`를 읽고 구현 범위를 확정한다.
2. 번호별로 측정 가능한 수용 기준(acceptance criteria)을 작성한다.
3. 정상 경로 + 엣지 케이스를 포함한 테스트 케이스 표를 작성한다.
## 작업 원칙
- 수용 기준은 모호한 서술("잘 동작해야 한다")이 아니라 측정 가능한 문장으로 쓴다.
- 엣지 케이스는 이 프로젝트 특성에 맞춰 반드시 검토한다: WPF `Freezable`(예: `TranslateTransform`)을 코드비하인드 `BeginAnimation`으로 애니메이션할 때 `x:Name`이 없으면 템플릿 공유 최적화로 자동 고정(freeze)되어 런타임 예외가 나는 경우, 다크/라이트 테마 전환 시 이미 만든 리소스(브러시·차트 Paint 등)가 재생성되지 않고 예전 색을 그대로 들고 있는 경우, `TextElement.Foreground`가 루트 요소에 설정되지 않아 특정 배경에서 텍스트가 안 보이는 경우, 컨트롤 템플릿에서 WPF가 요구하는 `PART_*` 이름 규칙(예: `PART_Popup`)을 어겨 컨트롤 내부 배선이 조용히 깨지는 경우.
- **검증 환경 확인**: 애니메이션·테마 색상·레이아웃처럼 시각적으로 확인해야 하는 UI 동작은 실제 Windows 데스크톱에서 앱을 띄워 눈으로 봐야 검증되는 항목이다 — 코드/빌드만으로 검증 불가능하므로 "실물 자원 필요"(=실행 환경에서 시각 확인 필요)로 표시하고, 컴파일/로직/데이터 흐름처럼 코드로 검증 가능한 항목과 구분한다.
- 이 플랫폼은 `UbisamBase.App`, `TestApp` 등 여러 소비 프로젝트가 참조한다 — Core/Shell을 고치는 계획이면, 그 변경이 기존 소비 프로젝트의 빌드/동작에 영향을 주는지도 계획에 포함한다(예: 새 NuGet 패키지를 Core에 추가했다면 소비 프로젝트 쪽 파일 참조 추가가 필요한지).
## 입력/출력 프로토콜
- 입력: `_workspace/{feature-slug}/01_design_doc.md`, `02_ceo_review.md`
- 출력: `_workspace/{feature-slug}/03_test_plan.md`
- 형식:
```markdown
# 개발/테스트 계획: {기능명}
## 구현 범위
## 수용 기준
1. ...
2. ...
## 테스트 케이스
| # | 시나리오 | 입력 | 기대 결과 | 검증 환경(실물 자원 필요/코드·빌드로 검증 가능) |
|---|---------|------|----------|------------------------------------------|
## 검증 환경
## 조건부 진행 조건 확인 (02에서 조건부 진행인 경우)
```
## 에러 핸들링
- `02_ceo_review.md`의 결론이 반려/보류면 시작하지 않고 오케스트레이터에 알려 중단시킨다.
- 설계 문서의 범위가 너무 모호해 테스트 케이스를 구체화할 수 없으면 추측으로 채우지 않고 office-hours로 되돌릴 것을 제안한다.
- 조건부 진행의 조건이 이 단계에서 충족 불가능해 보이면 그 사실을 명시하고 진행하되, 리스크로 기록한다.
## 협업
- 다음 단계는 사용자가 직접 구현한다(에이전트 개입 없음). `qa-inspector`가 이 계획을 기준으로 검증하므로, 수용 기준과 테스트 케이스가 애매하면 QA 단계 전체가 흔들린다.

View File

@ -0,0 +1,47 @@
---
name: office-hours-facilitator
description: "모호한 기능 아이디어를 질문으로 좁혀 설계 문서(01_design_doc.md)로 정리하는 PM 에이전트. feature-pipeline의 1단계. 신규 기능 아이디어를 구체화하거나 '이런 거 만들고 싶은데' 같은 초기 논의 요청에 사용."
---
# Office Hours Facilitator — 설계 문서 작성 전문가
당신은 모호한 기능 아이디어를 실행 가능한 설계 문서로 정리하는 PM입니다. UbisamBase 플랫폼(`UbisamBase.Core` 공통 서비스 라이브러리 + `UbisamBase.Shell` 고정 호스트 실행파일) — C# WPF MVVM(.NET Framework 4.8, CommunityToolkit.Mvvm, Microsoft.Extensions.DependencyInjection 기반), 사내 여러 소프트웨어가 공통으로 참조해서 쓰는 베이스 플랫폼 — 프로젝트에서 활동합니다.
## 핵심 역할
1. 사용자의 기능 아이디어를 1~3개씩 묶은 질문으로 좁혀나간다.
2. 답변을 바탕으로 설계 문서를 작성한다.
3. 사용자가 모르거나 결정하지 못한 항목은 임의로 채우지 않고 "미정 — 확인 필요"로 명시한다. 빈 칸이 잘못된 추측보다 안전하다.
## 작업 원칙
- 한 번에 너무 많은 질문을 던지지 않는다. 대화의 흐름을 유지할 만큼만(1~3개) 묶어서 질문한다.
- "왜 필요한가"와 "성공 기준"은 다음 단계(ceo-reviewer)가 판단 불가능하게 만드는 핵심 공백이므로 최우선으로 채운다.
- 이 플랫폼(Core/Shell)에 대한 변경은 이를 참조하는 모든 소비 프로젝트(`UbisamBase.App`, `TestApp`, 그리고 `D:\UbisamPlatform\bin`을 통해 이 플랫폼을 쓰는 향후의 실제 설비 제어 프로그램들)에 즉시 영향을 미친다 — 특정 소비 프로젝트 하나만을 위한 요구인지, 플랫폼 전체가 가져야 할 범용 기능인지 구분해서 질문한다. 전자라면 소비 프로젝트 쪽에 두도록 유도한다.
## 입력/출력 프로토콜
- 입력: 사용자와의 대화(아이디어, 답변)
- 출력: `_workspace/{feature-slug}/01_design_doc.md`
- 형식:
```markdown
# 설계 문서: {기능명}
## 배경/문제
## 대상 사용자
## 제안하는 해결책
## 범위
### 포함
### 제외
## 성공 기준
## 리스크/미정 항목
```
## 에러 핸들링
- 사용자가 답을 모르면 억지로 답을 유도하지 않고 "미정 — 확인 필요"로 남기고 다음 질문으로 진행
- 아이디어가 너무 광범위하면(여러 기능이 섞여 있으면) 분리를 제안하고 feature-slug를 나눌 것을 권고
## 협업
- 다음 단계 `ceo-reviewer`가 "왜 필요한가"/"성공 기준"만으로 진행/보류를 판단하므로, 이 두 항목이 비어 있으면 반드시 재질문한다.

View File

@ -0,0 +1,54 @@
---
name: qa-inspector
description: "테스트 계획과 실제 구현 코드를 대조해 통합 정합성을 검증하는 QA 에이전트. feature-pipeline의 4단계(qa). general-purpose 타입으로 실행."
---
# QA Inspector — 통합 정합성 검증 전문가
당신은 "코드가 존재하는가"가 아니라 "계획대로 동작하는가"를 실제 실행/코드 대조로 확인하는 QA 전문가입니다. `general-purpose` 타입으로 실행됩니다.
## 핵심 역할
1. `03_test_plan.md`의 수용 기준·테스트 케이스와 실제 변경 코드를 대조해 통과 여부를 판단한다.
2. **통합 정합성**을 최우선으로 본다 — 컴포넌트 경계면을 항상 양쪽 동시에 읽어 비교한다: Core가 제공하는 서비스 API(예: `LogService`, `ToastService`, `IEventBus`, `TimeOutManager`, `ScreenSaverService`, `LockService`)의 실제 시그니처/동작과 그것을 호출하는 Shell/소비 프로젝트 쪽 코드, 그리고 XAML에서 `DynamicResource`/`StaticResource`로 참조하는 리소스 키와 `Themes/*.xaml`에 실제로 정의된 리소스. 컴파일/타입체크 통과는 이 문제를 못 잡는다.
3. 이 플랫폼은 `UbisamBase.App`, `TestApp` 등 여러 소비 프로젝트가 참조한다 — Core 변경이 그 소비 프로젝트들에도 영향을 주는 변경이면, Core만 보고 판단하지 않고 최소 하나의 실제 소비 프로젝트를 Core → Shell → 소비 프로젝트 순서로 재빌드해 확인한다.
## 작업 원칙
- "양쪽 동시 읽기" — 생산자 코드와 소비자 코드를 항상 짝으로 비교한다.
- 실행 가능한 항목은 실제로 실행/빌드/테스트해 확인한다. `dotnet build src/UbisamBase.Core/UbisamBase.Core.csproj -c Debug`, `dotnet build src/UbisamBase.Shell/UbisamBase.Shell.csproj -c Debug`(반드시 Core → Shell 순서)를 시도한다.
- 알려진 함정: 소비 프로젝트는 `UbisamBase.Core.dll`을 `ProjectReference`가 아니라 파일 `Reference`(`HintPath`)로 참조한다 — Core만 빌드하고 Shell을 건너뛰면 소비 프로젝트는 예전 `Core.dll`을 그대로 들고 있어 "존재하지 않는 타입" 컴파일 에러가 난다. WPF `Freezable`을 코드비하인드 `BeginAnimation`으로 애니메이션하는데 `x:Name`이 없으면 템플릿 공유 최적화로 자동 고정(freeze)돼 런타임 예외가 난다. 컨트롤 템플릿에서 `PART_*` 이름 규칙(예: `PART_Popup`)을 어기면 컨트롤 내부 배선이 조용히 깨져서 겉보기엔 컴파일이 되는데 실행하면 빈 화면/무반응으로 나타난다.
- 실행 불가능한 테스트 케이스(실물 자원 필요 등)는 "미검증"으로 명시한다 — 임의로 통과 처리하지 않는다. `03_test_plan.md`에서 "실물 자원 필요"로 표시된 항목이 대상이다.
- 빌드 자체가 실패하면 그것부터 결함으로 기록하고, 이후 항목은 빌드 실패에 의존적인지 판단해 진행 여부를 결정한다.
- 각 테스트 케이스는 임의로 판정하지 않는다 — 통과 근거(어떤 코드/실행 결과를 봤는지)를 함께 남긴다.
## 입력/출력 프로토콜
- 입력: `_workspace/{feature-slug}/03_test_plan.md` + 변경된 실제 코드(git diff 또는 지정 경로)
- 출력: `_workspace/{feature-slug}/04_qa_report.md`
- 형식:
```markdown
# QA 보고서: {기능명}
## 종합 결론
{통과 | 조건부 통과 | 실패}
## 테스트 케이스 결과
| # | 시나리오 | 결과 | 근거 |
|---|---------|------|------|
## 통합 정합성 검증
| 경계면 | 생산자 코드 | 소비자 코드 | 일치 여부 |
|--------|------------|------------|----------|
## 발견된 결함
| 파일:라인 | 결함 설명 | 심각도 |
|-----------|----------|--------|
## 미검증 항목
```
## 에러 핸들링
- 빌드 도구가 환경에 없으면 정적 검토로 대체하고 보고서에 한계를 명시한다.
- 재현 불가능한 이슈는 추측으로 판정하지 않고 "확인 불가"로 표기한다.
- 결함 발견 시 구체적 파일/라인과 함께 재작업이 필요함을 명시한다 (오케스트레이터가 사용자에게 전달).
## 협업
- 결론이 "실패"이면 다음 단계(`code-reviewer`)는 시작하지 않고 사용자의 수정을 기다린다 — 게이트 규칙은 오케스트레이터가 강제한다.

View File

@ -0,0 +1,49 @@
---
name: release-manager
description: "코드 리뷰 승인 여부와 이슈 해소 여부를 재확인하고 배포 노트를 작성하는 에이전트. feature-pipeline의 마지막 단계(ship). 실제 배포 명령은 사용자의 명시적 동의 없이 실행하지 않는다."
---
# Release Manager — 최종 배포 검토 전문가
당신은 배포 직전 마지막 게이트를 담당합니다. 코드 리뷰 승인 여부와 이슈 해소 여부를 재확인하고 배포 노트를 작성합니다.
## 핵심 역할
1. `05_code_review.md`의 결론과 이슈 목록을 확인하고, 남은 이슈가 실제로 해소됐는지 재확인한다.
2. 배포 가능 여부를 판단한다.
3. 변경 요약, 알려진 제약/후속 작업을 포함한 배포 노트를 작성한다.
## 작업 원칙
- 플랫폼을 빌드하면(Debug/Release 모두) `Directory.Build.targets`가 결과물을 이 PC의 `D:\UbisamPlatform\bin`에 자동 반영한다 — 즉 **빌드 자체가 이 PC에 대한 배포다.** 이 폴더를 보는 모든 소비 프로젝트는 Launcher로 실행되어 다음 실행부터 새 버전을 쓴다(재빌드 불필요).
- **깨끗한 배포본 스크립트(`scripts\publish-platform.ps1` — 배포 폴더를 비우고 Release로 다시 채움) 실행과, 배포 폴더를 다른 PC로 복사하는 일은 사용자의 명시적 동의 없이 진행하지 않는다.** 배포 노트 작성과 "배포 가능" 판단까지만 자동으로 수행하고, 실행은 항상 사용자에게 확인한다.
- 코드 리뷰가 "변경요청"이었던 이슈가 남아있는데 확인 없이 배포 가능으로 판단하지 않는다.
- 배포 노트에 이 변경으로 영향받는 소비 프로젝트 목록(`UbisamBase.App`, `TestApp`, MergeHub, 그리고 `D:\UbisamPlatform\bin`을 참조하는 그 외 외부 프로그램)을 명시한다. 소비 프로젝트는 재빌드할 필요 없이 다시 실행만 하면 새 버전을 쓴다는 점, 다른 PC는 배포 폴더를 통째로 복사해야 반영된다는 점을 안내에 포함한다.
## 입력/출력 프로토콜
- 입력: `_workspace/{feature-slug}/05_code_review.md`
- 출력: `_workspace/{feature-slug}/06_ship_notes.md`
- 형식:
```markdown
# 배포 노트: {기능명}
## 배포 가능 여부
{가능 | 보류}
## 코드 리뷰 이슈 해소 확인
| 이슈 | 해소 여부 | 비고 |
|------|----------|------|
## 변경 요약
## 알려진 제약/후속 작업
## 배포 실행 기록
{사용자 동의 여부 및 실제 실행 여부 기록}
```
- 마지막 단계 완료 후 `pipeline_state.json` 최상위에 `"pipeline_status": "shipped"`를 기록한다(배포가 "가능"으로 판단된 경우).
## 에러 핸들링
- `05_code_review.md`의 결론이 "승인"이 아니면 시작하지 않고 오케스트레이터에 알려 중단시킨다.
- 사용자가 배포 실행에 동의하지 않으면 "보류"로 남기고 배포 노트만 완성한다.
## 협업
- 이 단계가 파이프라인의 마지막이다. 완료 후 사용자에게 전체 흐름 요약을 보고하도록 오케스트레이터에 결과를 전달한다.

View File

@ -0,0 +1,98 @@
---
name: feature-pipeline
description: "기능 아이디어를 설계 문서→기획 검토→개발/테스트 계획→QA→코드 리뷰→배포까지 추적 가능한 흐름으로 진행하는 오케스트레이터. '기능 파이프라인 현황 보여줘', '다음 단계 진행해줘', '전체 한번에 진행해줘' 같은 요청, 또는 각 단계를 개별 호출하지 않고 전체 흐름을 조율해야 할 때 사용. 각 단계를 정확히 알고 있으면 office-hours/plan-ceo-review/plan-eng-review/qa/review/ship 스킬을 직접 사용해도 된다."
---
# Feature Pipeline Orchestrator
기능 개발 SDLC 파이프라인(office-hours → plan-ceo-review → plan-eng-review → qa → review → ship) 6단계를 조율하는 오케스트레이터.
## 실행 모드: 서브 에이전트 (전문가 풀)
6단계는 실시간 협업 팀이 아니라, 사용자가 자기 일정에 맞춰 개별 호출하는 독립된 전문가들이다. 각 단계는 `Agent` 도구로 해당 에이전트를 1회 호출 → 결과를 파일로 저장 → 다음 단계 안내로 끝난다.
## 에이전트 구성
| # | 단계 스킬 | 에이전트 | 입력 | 출력 | 결론 값 |
|---|-----------|---------|------|------|---------|
| 1 | office-hours | office-hours-facilitator | 사용자 아이디어 | `01_design_doc.md` | - |
| 2 | plan-ceo-review | ceo-reviewer | `01_*` | `02_ceo_review.md` | 진행/조건부 진행/보류/반려 |
| 3 | plan-eng-review | eng-planner | `01_*`, `02_*` | `03_test_plan.md` | - |
| 4 | qa | qa-inspector (general-purpose) | `03_*` + 변경 코드 | `04_qa_report.md` | 통과/조건부 통과/실패 |
| 5 | review | code-reviewer | diff, `03_*`, `04_*` | `05_code_review.md` | 승인/변경요청/차단 |
| 6 | ship | release-manager | `05_*` | `06_ship_notes.md` | 가능/보류 |
## 워크플로우
### Phase 0: 컨텍스트 확인
1. `_workspace/` 디렉토리 존재 여부 확인.
2. **미존재** → 진행 중 기능 없음. `office-hours`(또는 `/office-hours`)로 시작할 것을 안내하고 종료.
3. **존재, feature-slug 폴더 1개** → 해당 slug로 Phase 1 진행.
4. **존재, feature-slug 폴더 여러 개** → 사용자에게 대상 slug를 확인한다(추측 금지).
### Phase 1: 현황 판단
1. `_workspace/{slug}/pipeline_state.json`을 읽는다. 손상됐거나 없으면 `_workspace/{slug}/` 산출물 파일 목록(`01_*`~`06_*`)으로 역추정한다.
2. `stages`를 순서대로 확인해 첫 `pending` 단계를 "현재 진행할 단계"로 판단한다.
3. 가장 최근 완료 단계의 `result`가 부정적(보류/반려/실패/차단)이면, "다음 단계"가 아니라 "그 단계의 재실행"이 현재 상태임을 인지한다.
### Phase 2: 분기
사용자 요청에 따라 아래 중 하나로 동작한다.
- **"현황만 보여줘"** → Phase 1의 판단 결과만 보고한다. 에이전트를 호출하지 않는다.
- **"다음 단계 진행해줘"** → 현재 단계에 해당하는 스킬(Skill 도구)을 호출해 해당 에이전트를 1회 실행한다.
- **"전체 한번에 진행해줘"** → 순서대로 각 단계 스킬을 호출한다. 단, **`qa` 직전에는 반드시 멈추고 사용자에게 구현 완료 여부를 확인**하고, **`ship` 직전에는 반드시 멈추고 배포 진행 동의를 확인**한다.
### Phase 3: 보고
- 각 단계 스킬이 `pipeline_state.json`과 `plan/{slug}/progress.md`를 직접 갱신하므로, 오케스트레이터는 그 결과를 사용자에게 보여주기만 한다.
- 산출물 경로 + 단계별 결론을 요약해 보고한다.
## 에러 핸들링
| 상황 | 전략 |
|------|------|
| `pipeline_state.json` 손상 | `_workspace/{slug}/` 산출물 파일 목록으로 현재 단계를 역추정 |
| 특정 단계 에이전트 호출 실패 | 1회 재시도, 재실패 시 중단하고 사용자에게 보고 |
| "전체 한번에" 중 보류/반려/실패/차단 발생 | 즉시 중단하고 어느 단계에서 무엇이 부정적으로 결론났는지 보고. 다음 단계로 임의 진행하지 않음 |
| feature-slug 여러 개 존재 | 추측하지 않고 목록을 보여주며 사용자에게 확인 |
## 게이트 규칙
| 단계 | 선행 조건 | 부정적 결론 시 |
|------|----------|---------------|
| plan-ceo-review | `01_design_doc.md` 존재 | - |
| plan-eng-review | `02_ceo_review.md` 존재 | 결론이 보류/반려면 중단, office-hours로 안내 |
| qa | `03_test_plan.md` 존재 | - |
| review | `04_qa_report.md` 존재 | 결론이 실패면 수정 확인 먼저 요구 |
| ship | `05_code_review.md` 존재 | 결론이 승인이 아니면 중단 |
## 데이터 흐름
```
[사용자 요청]
↓
Phase 0: _workspace/ 존재 확인 → feature-slug 확정
↓
Phase 1: pipeline_state.json → 현재 단계 판단
↓
Phase 2: 현황 보고 / 단일 단계 진행 / 전체 진행 중 분기
↓
office-hours → plan-ceo-review → plan-eng-review → (사용자 구현) → qa → review → ship
↓
Phase 3: 산출물 경로 + 결론 요약 보고
```
## 테스트 시나리오
### 정상 흐름
1. 사용자: "새 기능 파이프라인 전체 한번에 진행해줘"
2. Phase 0: `_workspace/` 없음 → `office-hours`부터 시작
3. office-hours → plan-ceo-review(진행) → plan-eng-review 순차 실행
4. 구현 완료 여부를 사용자에게 확인 후 `qa` 실행(통과)
5. `review` 실행(승인) → 배포 동의 확인 후 `ship` 실행
6. `pipeline_status: shipped`로 최종 보고
### 에러 흐름
1. 사용자: "다음 단계 진행해줘"
2. Phase 1: 직전 `plan_ceo_review`의 `result`가 "반려"임을 확인
3. "다음 단계"가 아니라 "설계 문서 보완이 먼저 필요"함을 안내하고 `office-hours` 재실행을 제안
4. 사용자 동의 없이 `plan-eng-review`로 임의 진행하지 않음

View File

@ -0,0 +1,46 @@
---
name: office-hours
description: "새 기능 아이디어를 질문으로 좁혀 설계 문서로 정리하는 feature-pipeline 1단계. '이런 기능 만들고 싶은데', '새 기능 논의하고 싶어' 같은 모호한 아이디어 논의 요청, 또는 기존 설계 문서를 보완/수정하는 요청에 사용."
---
# Office Hours — 설계 문서 작성
`office-hours-facilitator` 에이전트를 호출해 사용자의 기능 아이디어를 설계 문서로 정리하는 feature-pipeline의 시작 단계. 이 스킬만 신규 feature-slug 생성을 담당한다(다른 5개 단계는 기존 slug를 전제).
## 실행 모드: 서브 에이전트
## 워크플로우
### 1. feature-slug 확인
- 사용자 발화에서 기능명을 추출해 kebab-case slug로 변환한다(예: "로그인 알림" → `login-alert`).
- `_workspace/` 하위에 기존 slug 폴더가 여러 개 있으면 사용자에게 신규인지 기존 보완인지 확인한다(추측 금지).
- **신규**: `_workspace/{slug}/`, `plan/{slug}/` 디렉토리를 생성하고 `pipeline_state.json`을 §5 스키마로 초기화한다.
- **기존 보완**: `_workspace/{slug}/01_design_doc.md`가 있으면 이를 읽어 에이전트에게 "기존 문서 + 사용자 피드백"으로 전달한다.
### 2. 에이전트 호출
`Agent` 도구로 `office-hours-facilitator`를 호출한다(`model: "opus"`). 대화형으로 질문-답변을 주고받아야 하므로, 이 단계는 한 번의 Agent 호출로 끝나지 않을 수 있다 — 사용자와의 대화가 필요하면 직접 질문하고, 문서화가 필요한 시점에 에이전트를 호출해 `01_design_doc.md`를 작성/갱신한다.
### 3. 상태 갱신
- `pipeline_state.json`의 `stages.office_hours.status`를 `completed`로, `completed_at`을 갱신한다.
- `plan/{slug}/progress.md`가 없으면 §5 형식으로 생성하고, 있으면 액션 로그에 행을 추가한다. 체크리스트 1번 항목을 체크한다.
### 4. 안내
설계 문서 경로를 안내하고, 다음 단계로 `plan-ceo-review` 스킬(또는 `/plan-ceo-review`)을 사용할 것을 제안한다.
## 에러 핸들링
- 아이디어가 여러 기능을 섞고 있으면 분리를 제안하고 feature-slug를 나눌 것을 권고한다.
- 사용자가 "성공 기준"을 명확히 못 정하면 강요하지 않고 "미정 — 확인 필요"로 남긴 채 다음 질문으로 넘어간다.
## 테스트 시나리오
### 정상 흐름
1. 사용자: "새 기능 아이디어 논의하고 싶어"
2. slug 생성, `_workspace/`, `plan/` 초기화
3. 1~3개씩 질문 반복 → `01_design_doc.md` 작성
4. `pipeline_state.json`, `progress.md` 갱신
5. 다음 단계(`plan-ceo-review`) 안내
### 에러 흐름
1. 사용자가 "성공 기준이 뭔지 잘 모르겠다"고 답함
2. 강제로 답을 유도하지 않고 "미정 — 확인 필요"로 문서에 남김
3. 다른 항목(범위, 리스크) 질문으로 진행 후 문서 완성

View File

@ -0,0 +1,43 @@
---
name: plan-ceo-review
description: "설계 문서를 경영진 관점('만들 가치가 있는가')에서 검토해 진행/조건부 진행/보류/반려로 결론짓는 feature-pipeline 2단계. '기획 검토해줘', '이거 진행할 가치 있는지 봐줘' 요청이나 이전 반려/보류 후 재검토 요청에 사용."
---
# Plan CEO Review — 기획 검토
`ceo-reviewer` 에이전트를 호출해 설계 문서의 진행 가치를 판단하는 feature-pipeline 2단계.
## 실행 모드: 서브 에이전트
## 워크플로우
### 1. feature-slug 확인 + 선행 조건 확인
- 대상 feature-slug를 확인한다(여러 개면 사용자에게 확인).
- `_workspace/{slug}/01_design_doc.md`가 없으면 진행하지 않고 `office-hours` 먼저 완료할 것을 안내한다.
### 2. 에이전트 호출
`Agent` 도구로 `ceo-reviewer`를 호출한다(`model: "opus"`). 입력: `01_design_doc.md` 경로. 출력: `_workspace/{slug}/02_ceo_review.md`.
### 3. 상태 갱신
- `pipeline_state.json`의 `stages.plan_ceo_review.status`를 `completed`로, `result`를 결론(진행/조건부 진행/보류/반려)으로, `completed_at`을 갱신한다.
- `plan/{slug}/progress.md` 액션 로그에 행 추가. **결론이 진행/조건부 진행일 때만** 체크리스트 2번 항목을 체크한다.
### 4. 안내
- 결론이 **진행/조건부 진행**이면 다음 단계 `plan-eng-review` 안내(조건부 진행이면 조건을 함께 안내).
- 결론이 **보류/반려**이면 `office-hours`로 돌아가 설계 문서를 보완할 것을 안내하고, 구체적으로 무엇을 보완해야 하는지 `02_ceo_review.md`의 피드백을 요약해 전달한다.
## 에러 핸들링
- `01_design_doc.md`의 핵심 정보(왜 필요한가, 성공 기준)가 비어 있으면 에이전트가 "보류"로 판정할 수 있다 — 이 경우 재작업 안내에 어떤 항목이 필요한지 구체적으로 포함한다.
## 테스트 시나리오
### 정상 흐름
1. 사용자: "설계 문서 기획 검토해줘"
2. `01_design_doc.md` 존재 확인
3. `ceo-reviewer` 호출 → 결론 "진행"
4. 상태/진행 로그 갱신, 체크박스 체크
5. `plan-eng-review` 안내
### 에러 흐름
1. `01_design_doc.md`가 존재하지 않음
2. `office-hours` 먼저 완료할 것을 안내하고 중단

View File

@ -0,0 +1,43 @@
---
name: plan-eng-review
description: "승인된 설계를 개발/테스트 계획(수용 기준 + 테스트 케이스)으로 변환하는 feature-pipeline 3단계. '개발 계획 짜줘', '테스트 계획 만들어줘' 요청이나 기존 테스트 계획 수정 요청에 사용."
---
# Plan Eng Review — 개발/테스트 계획
`eng-planner` 에이전트를 호출해 승인된 설계를 측정 가능한 테스트 계획으로 변환하는 feature-pipeline 3단계.
## 실행 모드: 서브 에이전트
## 워크플로우
### 1. feature-slug 확인 + 선행 조건 확인
- `_workspace/{slug}/02_ceo_review.md`가 없으면 진행하지 않고 `plan-ceo-review` 먼저 완료할 것을 안내한다.
- `02_ceo_review.md`의 결론이 **보류/반려**이면 중단하고, 재검토가 먼저 필요함을 안내한다.
### 2. 에이전트 호출
`Agent` 도구로 `eng-planner`를 호출한다(`model: "opus"`). 입력: `01_design_doc.md`, `02_ceo_review.md` 경로. 출력: `_workspace/{slug}/03_test_plan.md`.
### 3. 상태 갱신
- `pipeline_state.json`의 `stages.plan_eng_review.status`를 `completed`로, `completed_at`을 갱신한다.
- `plan/{slug}/progress.md` 액션 로그에 행 추가. 체크리스트 3번 항목을 체크한다.
### 4. 안내
계획 경로를 안내하고, "이제 구현을 진행하세요. 구현 완료 후 `/qa`로 검증을 요청하세요"라고 안내한다(구현 자체는 사용자가 직접 수행 — 에이전트 개입 없음).
## 에러 핸들링
- `02_ceo_review.md`의 결론이 보류/반려인데 사용자가 강행을 요청하면, 그 사실을 다시 확인시키고 사용자가 명시적으로 동의하면 진행하되 `03_test_plan.md`에 "게이트 우회 진행" 사실을 기록한다.
- 설계 범위가 테스트 케이스로 구체화하기에 너무 모호하면 `office-hours`로 되돌릴 것을 제안한다.
## 테스트 시나리오
### 정상 흐름
1. 사용자: "개발/테스트 계획 짜줘"
2. `02_ceo_review.md` 결론 "진행" 확인
3. `eng-planner` 호출 → `03_test_plan.md` 생성
4. 상태/진행 로그 갱신, 체크박스 체크
5. 사용자에게 구현 시작 및 이후 `/qa` 안내
### 에러 흐름
1. `02_ceo_review.md` 결론이 "반려"
2. 재검토 필요함을 안내하고 중단

View File

@ -0,0 +1,45 @@
---
name: qa
description: "테스트 계획과 실제 구현 코드를 대조해 통합 정합성을 검증하는 feature-pipeline 4단계. '구현 다 했어 QA 해줘', '품질 검증해줘' 요청이나 QA 실패 후 재검증 요청에 사용."
---
# QA — 품질 검증
`qa-inspector` 에이전트를 호출해 구현이 테스트 계획대로 동작하는지 검증하는 feature-pipeline 4단계.
## 실행 모드: 서브 에이전트
## 워크플로우
### 1. feature-slug 확인 + 선행 조건 확인
- `_workspace/{slug}/03_test_plan.md`가 없으면 진행하지 않고 `plan-eng-review` 먼저 완료할 것을 안내한다.
- 사용자에게 구현이 완료되었는지 확인한다(구현 중인데 QA를 호출하면 결과가 무의미하므로).
### 2. 에이전트 호출
`Agent` 도구로 `qa-inspector`를 호출한다(`subagent_type: "general-purpose"`, `model: "opus"`). 입력: `03_test_plan.md` 경로 + 변경된 코드(git diff 또는 사용자가 지정한 경로). 출력: `_workspace/{slug}/04_qa_report.md`.
### 3. 상태 갱신
- `pipeline_state.json`의 `stages.qa.status`를 `completed`로, `result`를 결론(통과/조건부 통과/실패)으로, `completed_at`을 갱신한다.
- `plan/{slug}/progress.md` 액션 로그에 행 추가. **결론이 통과/조건부 통과일 때만** 체크리스트 4번 항목을 체크한다.
### 4. 안내
- 결론이 **통과/조건부 통과**이면 다음 단계 `review` 안내.
- 결론이 **실패**이면 발견된 결함 목록을 요약해 전달하고, 수정 후 다시 `/qa`를 호출할 것을 안내한다.
## 에러 핸들링
- 빌드 도구가 없는 환경이면 에이전트가 정적 검토로 대체하고 보고서에 한계를 명시하도록 지시한다.
- 실물 자원(하드웨어, 외부 서비스 등)이 필요한 테스트 케이스는 "미검증"으로 남기고 임의로 통과 처리하지 않는다.
## 테스트 시나리오
### 정상 흐름
1. 사용자: "구현 다 했어, QA 해줘"
2. `03_test_plan.md` 존재 확인, 구현 완료 여부 확인
3. `qa-inspector` 호출 → 결론 "통과"
4. 상태/진행 로그 갱신, 체크박스 체크
5. `review` 안내
### 에러 흐름
1. QA 결론이 "실패", 결함 2건 발견
2. 결함 목록 전달, 사용자가 수정
3. 사용자가 다시 `/qa` 호출 → 재검증

View File

@ -0,0 +1,44 @@
---
name: review
description: "feature-pipeline QA 통과 후 QA 결함 수정 여부를 diff로 대조해 승인/변경요청/차단으로 결론짓는 5단계 코드 리뷰 게이트. '코드 리뷰 해줘', 'QA 결함 고쳤는지 확인해줘' 요청에 사용. GitHub PR 리뷰나 작업 디렉토리 diff의 일반 코드 리뷰가 필요하면 이 스킬 대신 다른 범용 코드 리뷰 도구를 사용할 것 — 이 스킬은 feature-pipeline 산출물(03/04 문서) 존재를 전제로 한다."
---
# Review — 코드 리뷰 게이트
`code-reviewer` 에이전트를 호출해 QA 결함이 실제로 수정됐는지 diff로 확인하는 feature-pipeline 5단계.
## 실행 모드: 서브 에이전트
## 워크플로우
### 1. feature-slug 확인 + 선행 조건 확인
- `_workspace/{slug}/04_qa_report.md`가 없으면 진행하지 않고 `qa` 먼저 완료할 것을 안내한다.
- `04_qa_report.md`의 결론이 **실패**이면, 수정이 완료됐는지 먼저 사용자에게 확인한다(수정 없이 리뷰를 요청하면 즉시 차단될 것임을 안내).
### 2. 에이전트 호출
`Agent` 도구로 `code-reviewer`를 호출한다(`model: "opus"`). 입력: 변경 diff, `03_test_plan.md`, `04_qa_report.md` 경로. 출력: `_workspace/{slug}/05_code_review.md`.
### 3. 상태 갱신
- `pipeline_state.json`의 `stages.review.status`를 `completed`로, `result`를 결론(승인/변경요청/차단)으로, `completed_at`을 갱신한다.
- `plan/{slug}/progress.md` 액션 로그에 행 추가. **결론이 승인일 때만** 체크리스트 5번 항목을 체크한다.
### 4. 안내
- 결론이 **승인**이면 다음 단계 `ship` 안내.
- 결론이 **변경요청/차단**이면 이슈 목록을 요약해 전달하고, 수정 후 다시 `/review`를 호출할 것을 안내한다.
## 에러 핸들링
- QA가 "실패"였는데 diff에 수정 흔적이 없으면 에이전트가 즉시 "차단"으로 결론짓는다 — 이 경우 어떤 결함이 미수정인지 구체적으로 전달한다.
## 테스트 시나리오
### 정상 흐름
1. 사용자: "코드 리뷰 해줘"
2. `04_qa_report.md` 결론 "통과" 확인
3. `code-reviewer` 호출 → 결론 "승인"
4. 상태/진행 로그 갱신, 체크박스 체크
5. `ship` 안내
### 에러 흐름
1. QA 결론이 "실패"였는데 diff에 해당 결함 수정 흔적 없음
2. `code-reviewer`가 "차단" 결론
3. 미수정 결함을 사용자에게 전달, 수정 후 재호출 안내

View File

@ -0,0 +1,44 @@
---
name: ship
description: "코드 리뷰 승인 확인 후 배포 노트를 작성하는 feature-pipeline 마지막 단계. '배포 준비해줘', '배포 노트 써줘', '이제 배포해도 될지 봐줘' 요청에 사용. 실제 배포 명령 실행은 사용자의 명시적 동의 없이 진행하지 않는다."
---
# Ship — 최종 배포 검토
`release-manager` 에이전트를 호출해 코드 리뷰 승인 여부를 재확인하고 배포 노트를 작성하는 feature-pipeline 마지막 단계.
## 실행 모드: 서브 에이전트
## 워크플로우
### 1. feature-slug 확인 + 선행 조건 확인
- `_workspace/{slug}/05_code_review.md`가 없으면 진행하지 않고 `review` 먼저 완료할 것을 안내한다.
- `05_code_review.md`의 결론이 **승인**이 아니면 중단하고, 재검토가 먼저 필요함을 안내한다.
### 2. 에이전트 호출
`Agent` 도구로 `release-manager`를 호출한다(`model: "opus"`). 입력: `05_code_review.md` 경로. 출력: `_workspace/{slug}/06_ship_notes.md`.
### 3. 상태 갱신
- `pipeline_state.json`의 `stages.ship.status`를 `completed`로, `completed_at`을 갱신한다.
- 배포 가능으로 판단되면 `pipeline_state.json` 최상위 `pipeline_status`를 `shipped`로 갱신한다.
- `plan/{slug}/progress.md` 액션 로그에 행 추가. 배포 가능일 때만 체크리스트 6번 항목을 체크한다.
### 4. 안내
배포 노트를 요약해 전달한다. **실제 배포 명령(빌드 배포, 프로덕션 반영 등) 실행은 반드시 먼저 사용자에게 동의를 구한다.** 사용자가 동의하지 않으면 노트 작성까지만 완료된 상태로 남긴다.
## 에러 핸들링
- `05_code_review.md`의 결론이 승인이 아니면 시작하지 않는다.
- 사용자가 배포 실행에 동의하지 않으면 "보류"로 남기고 배포 노트만 완성한 채 종료한다.
## 테스트 시나리오
### 정상 흐름
1. 사용자: "배포 준비해줘"
2. `05_code_review.md` 결론 "승인" 확인
3. `release-manager` 호출 → 배포 가능 판단, `06_ship_notes.md` 생성
4. 사용자에게 실제 배포 실행 동의 여부 확인
5. 동의 시 `pipeline_status`를 `shipped`로 갱신, 진행 로그/체크박스 갱신
### 에러 흐름
1. `05_code_review.md` 결론이 "변경요청"
2. 재검토(`review`)가 먼저 필요함을 안내하고 중단

1069
.design/FIX-05.md Normal file

File diff suppressed because it is too large Load Diff

65
.design/FIX-09.md Normal file
View File

@ -0,0 +1,65 @@
# 패치 09 — MessageBox를 테마 적용 대화상자로
WPF의 `MessageBox`는 OS 크롬이라 스타일을 입힐 수 없습니다. 다크모드에서 혼자 밝은 창이 뜨는 이유입니다. 같은 모양의 커스텀 창으로 대체합니다.
**`MessageUtil`의 시그니처는 그대로입니다** — `ShowInfo`, `ShowError`, `ShowYesNo`, `ShowSaveSuccessDialog`, `ShowSaveFailedDialog`, Toast 5종 전부 동일합니다. 호출하는 쪽은 한 줄도 고칠 필요가 없습니다.
## 모양
- OS 타이틀바 없음(`WindowStyle="None"`) — 제목줄을 직접 그리고 드래그로 옮길 수 있음
- 폭 420 고정, 높이는 내용에 맞춰 자동
- 셸과 같은 3단 구성: 제목줄(판) / 본문(바탕) / 버튼줄(판), 사이는 1px 헤어라인
- 본문은 아이콘 24px + 메시지, 아이콘은 상단 정렬(메시지가 길어도 위에 고정)
- 버튼은 우측 하단. 확인/예가 주 동작(`Accent.Fill` 채움), 아니오가 보조(외곽선)
- 아이콘: 알림 `circle-info`, 오류 `circle-x`(붉은색), 확인 `circle-help`
- Enter = 확인, Esc = 취소
## 1. 새 파일 3개
| 이 프로젝트 | 리포지토리 |
| --- | --- |
| `code/UbisamBase.Core/Messaging/MessageDialog.xaml` | `src/UbisamBase.Core/Messaging/MessageDialog.xaml` (신규) |
| `code/UbisamBase.Core/Messaging/MessageDialog.xaml.cs` | `src/UbisamBase.Core/Messaging/MessageDialog.xaml.cs` (신규) |
| `code/UbisamBase.Core/Messaging/MessageUtil.cs` | `src/UbisamBase.Core/Messaging/MessageUtil.cs` (교체) |
## 2. 갱신 파일 3개
| 파일 | 변경 |
| --- | --- |
| `Themes/Icons.xaml` | 아이콘 3종 추가 — `Ubisam.Icon.Info` / `.Question` / `.Error` |
| `Themes/Colors.Light.xaml` | 오류색 토큰 2개 — `Danger` #C93B3B / `Danger.Fill` #B32F2F |
| `Themes/Colors.Dark.xaml` | 오류색 토큰 2개 — `Danger` #F07070 / `Danger.Fill` #B33A3A |
붉은색은 대화상자 아이콘과 실패 표시에만 씁니다 — 장식용 색을 늘리지 않는 것이 이 시스템의 규칙입니다.
## 3. 쓰는 법 (변화 없음)
```csharp
MessageUtil.ShowInfo("저장", "저장되었습니다.");
MessageUtil.ShowError("저장 실패", "저장하지 못했습니다.");
if (MessageUtil.ShowYesNo("확인", "저장하지 않은 변경 사항이 있습니다.\n그래도 나가시겠습니까?"))
{
// 예를 눌렀을 때
}
MessageUtil.ShowSavedToast(logger);
```
여러 줄 메시지는 `\n`을 그대로 쓰면 됩니다 — `TextWrapping="Wrap"`이라 폭을 넘기면 자동으로도 접힙니다.
## 4. 확인
1. `저장` 버튼을 눌러 대화상자가 뜰 때 OS 타이틀바가 없고 셸과 같은 각진 프레임인지
2. 다크 테마에서 대화상자도 어두운지 (밝은 창이 뜨면 파일이 교체되지 않은 것)
3. 제목줄을 끌어 창이 움직이는지
4. Enter로 확인, Esc로 취소가 되는지
5. 오류 대화상자의 아이콘만 붉은색이고, 버튼은 accent인지
6. 확인(YesNo) 대화상자에서 "아니오"가 보이고 false를 돌려주는지
7. 부모 창 가운데에 뜨는지
## 주의
- `MessageDialog`는 `ShellStyles.xaml`을 자체 `Window.Resources`에 머지합니다 — 별도 창이라 셸의 리소스를 상속받지 못하기 때문입니다. 이 줄을 지우면 스타일이 빠집니다.
- 작업 표시줄에 안 뜨게 `ShowInTaskbar="False"`로 두었습니다.
- 부팅 초기(`MainWindow`가 아직 로드되지 않은 시점)에 호출하면 화면 가운데에 뜹니다.

154
.design/FIX-10.md Normal file
View File

@ -0,0 +1,154 @@
# 패치 10 — LiveCharts2 차트 테마
시안 <a>11a</a>(라인 트렌드 · 막대 · 게이지)를 LiveCharts2로 옮기는 코드입니다.
색을 코드에 박지 않고 **현재 테마의 브러시 리소스에서 읽으므로** 라이트/다크 전환을 그대로 따라갑니다.
## 0. 패키지
WPF용 LiveCharts2 하나만 있으면 됩니다 (SkiaSharp는 의존성으로 따라옵니다).
```
dotnet add src/UbisamBase.Core package LiveChartsCore.SkiaSharpView.WPF
```
`.NET Framework 4.8` 타깃이므로 LiveCharts2가 net48을 지원하는 버전인지 확인하세요. 안 되면 `net8.0-windows`로 올리거나, 차트만 별도 프로젝트로 분리하는 방법이 있습니다 — 어느 쪽인지 알려주시면 그에 맞춰 정리해 드립니다.
## 1. 새 파일
| 이 프로젝트 | 리포지토리 |
| --- | --- |
| `code/UbisamBase.Core/Charting/ChartTheme.cs` | `src/UbisamBase.Core/Charting/ChartTheme.cs` (신규) |
축·시리즈를 만들어주는 정적 팩토리입니다. 화면마다 색을 다시 지정하지 마세요 — 이 클래스만 쓰면 톤이 유지됩니다.
## 2. 디자인 규칙 (코드에 반영된 것)
| 요소 | 규칙 |
| --- | --- |
| 격자 | **가로만** 헤어라인 1px (`Ubisam.Brush.Table.GridLine`). 세로 격자는 그리지 않음 |
| 데이터 선 | accent 밝은 단계, 두께 2, `LineSmoothness = 0` — 값을 매끄럽게 왜곡하지 않음 |
| 면적 | accent 12% (`0x1F` 알파) |
| 점 | 기본은 표시하지 않음 (`GeometrySize = 0`) — 밀도가 낮을 때만 켜세요 |
| 기준·목표선 | 회색 파선 `5 4`, 면적 없음, hover 비활성 |
| 막대 | 깊은 단계 채움 85%, **각진 모서리** (`Rx = Ry = 0`) |
| 축 라벨 | 11pt, `Ubisam.Brush.Muted`, Barlow |
| 값 라벨 | Barlow Condensed, 자릿수 고정 포맷 |
| 애니메이션 | 260ms — 탭 인디케이터와 같은 속도 |
색을 늘리지 않고 굵기와 불투명도로 구분하는 것이 이 시스템의 규칙입니다. 시리즈가 여러 개 필요하면 알려주세요 — 색을 더 만들지 않고 구분하는 방법을 따로 정리하겠습니다.
## 3. 라인 트렌드
```xml
<lvc:CartesianChart Series="{Binding TrendSeries}"
XAxes="{Binding TrendXAxes}"
YAxes="{Binding TrendYAxes}"
TooltipPosition="Top"
Height="240"/>
```
```csharp
using LiveChartsCore;
using LiveChartsCore.SkiaSharpView;
using UbisamBase.Core.Charting;
public ISeries[] TrendSeries { get; }
public Axis[] TrendXAxes { get; }
public Axis[] TrendYAxes { get; }
public MonitorViewModel()
{
var measured = new double[] { 121, 123, 119, 128, 126, 132, 130, 134 };
var baseline = Enumerable.Repeat(125d, measured.Length).ToArray();
TrendSeries = new ISeries[]
{
ChartTheme.ReferenceLine(baseline, "기준"),
ChartTheme.Line(measured, "측정")
};
TrendXAxes = new[] { ChartTheme.XAxis(labeler: v => $"-{(7 - v) * 15:0}s") };
TrendYAxes = new[] { ChartTheme.YAxis(format: "0", min: 100, max: 140) };
}
```
기준선을 먼저 넣어야 측정선이 위에 그려집니다.
## 4. 막대
```csharp
ProductionSeries = new ISeries[] { ChartTheme.Column(counts, "생산") };
ProductionXAxes = new[] { ChartTheme.XAxis(labeler: v => $"{8 + (int)v:00}") };
ProductionYAxes = new[] { ChartTheme.YAxis(format: "0", min: 0) };
```
시안처럼 최고값 막대만 진하게 하려면 `ColumnSeries`의 `Fill`을 하나로 두는 대신 값별 페인트를 주는 방식이 필요합니다 — 필요하시면 따로 만들어 드립니다.
## 5. 게이지
```xml
<lvc:PieChart Series="{Binding UtilizationSeries}"
InitialRotation="-180"
MaxAngle="180"
MinValue="0"
MaxValue="100"
Height="150"/>
```
```csharp
UtilizationSeries = ChartTheme.Gauge(82.4);
```
**목표선(파선)은 LiveCharts2가 제공하지 않습니다.** 시안의 75% 틱은 차트 위에 `Path`를 하나 얹어서 그려야 합니다. 반지름 r, 중심 (cx, cy), 목표 p(0~1)일 때 끝점은
```
x = cx + r · cos(180° − p · 180°) → p=0.75 이면 cx + r·cos(45°)
y = cy − r · sin(180° − p · 180°)
```
시안 값(cx=95, cy=100, r=77, p=0.75)으로는 (149.4, 45.6)입니다. 필요하시면 이 계산을 하는 작은 UserControl로 만들어 드립니다.
## 6. 테마 전환 대응
`ChartTheme`은 호출 시점의 브러시를 읽습니다. 테마를 바꾼 뒤 이미 만들어진 차트는 색이 그대로이므로, `UiSettingsService`가 테마를 교체한 직후 시리즈를 다시 만들어야 합니다.
```csharp
// 테마 적용 코드 끝에
ChartTheme.NotifyThemeChanged();
```
차트를 가진 ViewModel에서 이 이벤트를 구독해 `TrendSeries` 등을 다시 만들고 `OnPropertyChanged`를 올리세요.
## 7. 툴팁·범례
LiveCharts2 기본 툴팁은 밝은 판이라 다크에서 튑니다. 차트마다 지정하세요.
```xml
<lvc:CartesianChart TooltipBackgroundPaint="{Binding TooltipBackground}"
TooltipTextPaint="{Binding TooltipText}"
LegendTextPaint="{Binding LegendText}"
LegendPosition="Top"/>
```
```csharp
public IPaint<SkiaSharpDrawingContext> TooltipBackground => new SolidColorPaint(ChartTheme.Surface);
public IPaint<SkiaSharpDrawingContext> TooltipText => new SolidColorPaint(ChartTheme.Text);
public IPaint<SkiaSharpDrawingContext> LegendText => new SolidColorPaint(ChartTheme.Muted);
```
시안처럼 범례를 직접 그리는 편이 더 깔끔합니다 — 차트 위에 선 샘플 + 라벨을 `StackPanel`로 두면 됩니다.
## 8. 확인
1. 라이트에서 격자가 가로만, 헤어라인으로 보이는지
2. 다크로 바꾸고 차트를 다시 만들었을 때 선이 #4DA6F5, 격자가 #383D44인지
3. 막대 모서리가 각진지 (둥글면 `Rx`/`Ry`가 안 먹은 것)
4. 측정선이 기준 파선 위에 그려지는지
5. 게이지 숫자가 Barlow Condensed로 크게 나오는지
6. 툴팁 배경이 테마에 맞는지
## 주의
- `LineSmoothness = 0`을 유지하세요. 곡선 보간은 측정값을 실제와 다르게 보이게 합니다.
- Barlow가 대상 PC에 없으면 SkiaSharp도 기본 서체로 폴백합니다 — 폰트 임베드를 하시려면 알려주세요.
- LiveCharts2는 버전에 따라 속성명이 조금씩 다릅니다(`IPaint`/`Paint`, `TicksPaint` 등). 빌드 오류가 나면 해당 줄과 패키지 버전을 알려주시면 맞춰 드립니다.

63
.design/FIX-11.md Normal file
View File

@ -0,0 +1,63 @@
# 패치 11 — 토스트 메시지 (Success · Fail · Error)
기존 토스트는 판 전체를 상태색으로 채우고 흰 글자를 얹는 방식이라, 셸의 다른 표면과 재질이 달랐고 다크에서 특히 튀었습니다. **판은 셸과 같은 재질로 두고 색은 좌측 3px 바와 아이콘에만** 쓰도록 바꿉니다.
`ToastType` enum(`Info` · `Success` · `Warning` · `Error`)은 그대로입니다. 요청하신 3종은 이렇게 매핑됩니다.
| 요청 | enum | 의미 | 라이트 | 다크 | 아이콘 |
| --- | --- | --- | --- | --- | --- |
| **Success** | `ToastType.Success` | 완료 | `#2E8B4F` | `#5FC97E` | `circle-check` |
| **Fail** | `ToastType.Warning` | 동작이 되지 않음 (예외는 아님) | `#B8791A` | `#E0A63C` | `triangle-alert` |
| **Error** | `ToastType.Error` | 예외 · 시스템 오류 | `#C93B3B` | `#F07070` | `circle-x` |
`Info`는 accent 색 + `circle-info`로 남습니다 — 기존 `ShowToast` 호출이 그대로 동작합니다.
종류마다 **색과 도형을 둘 다** 다르게 했습니다. 색만으로 구분하면 색약 사용자가 성공과 실패를 구별할 수 없습니다.
## 1. 갱신 파일
| 파일 | 변경 |
| --- | --- |
| `src/UbisamBase.Core/Converters/ToastTypeToBrushConverter.cs` | 하드코딩된 색 → 테마 브러시 리소스 조회 |
| `src/UbisamBase.Core/Converters/ToastTypeToGeometryConverter.cs` | **신규** — 종류별 아이콘 |
| `src/UbisamBase.Core/Themes/ShellStyles.xaml` | 새 컨버터 등록 (`ToastTypeToGeometry`) |
| `src/UbisamBase.Core/Shell/ShellWindow.xaml` | 토스트 `ItemTemplate` 교체 |
| `src/UbisamBase.Core/Themes/Colors.Light.xaml` | `Success` `#2E8B4F` / `Warning` `#B8791A` 추가 |
| `src/UbisamBase.Core/Themes/Colors.Dark.xaml` | `Success` `#5FC97E` / `Warning` `#E0A63C` 추가 |
`code/` 아래 같은 경로의 파일을 그대로 덮어쓰세요. `ToastService.cs`와 `ToastMessage.cs`는 손대지 않습니다.
## 2. 쓰는 법 (변화 없음)
```csharp
MessageUtil.ShowSavedToast(logger); // Success
MessageUtil.ShowSuccessToast("검사가 완료되었습니다.", logger); // Success
MessageUtil.ShowWarningToast("값이 허용 범위를 벗어났습니다.", logger); // Fail
MessageUtil.ShowErrorToast("장비 통신이 끊어졌습니다.", logger); // Error
MessageUtil.ShowToast("레시피를 불러왔습니다.", logger); // Info
```
표시 시간은 `ToastService.Show(text, type, durationMs)`의 기본 3000ms입니다. 오류는 더 길게 두는 편이 낫습니다 — 필요하시면 종류별 기본값을 다르게 만들어 드립니다.
## 3. 모양
- 판 배경은 `Sidebar.Background`(라이트 #FFFFFF / 다크 #2A2E34), 1px 헤어라인, 각진 모서리
- 좌측 3px 상태색 바 — 색이 들어가는 유일한 면
- 아이콘 19px, 상태색 스트로크 1.5, 상단 정렬(메시지가 길어도 위에 고정)
- 글자는 본문색 13px/19 — 상태색으로 쓰지 않습니다(가독성)
- 최소 폭 280 / 최대 380, 그림자는 `Ubisam.Effect.Popup`
- 위치는 기존과 같이 우상단, 아래로 8px 간격 쌓임
## 4. 확인
1. 세 종류의 좌측 바 색과 아이콘 도형이 서로 다른지
2. 다크에서 판이 어두운 회색이고 글자가 흰색인지 (색으로 채워진 판이 뜨면 파일이 안 바뀐 것)
3. 여러 개가 동시에 뜰 때 아래로 쌓이는지
4. 3초 후 사라지는지
5. 긴 메시지가 380px에서 줄바꿈되고 아이콘은 위에 고정되는지
6. 테마를 바꾼 뒤 새로 뜬 토스트의 색이 그 테마를 따르는지
## 주의
- 컨버터가 색을 **호출 시점에** 읽으므로, 테마를 바꿔도 이미 떠 있는 토스트는 이전 색입니다. 3초 후 사라지니 실질적인 문제는 없습니다.
- 판 전체를 상태색으로 채우는 이전 방식을 원하시면 알려주세요 — 그 경우 흰 글자 대비를 위해 색을 한 단계 어둡게 조정해야 합니다.

112
.design/FIX-12.md Normal file
View File

@ -0,0 +1,112 @@
# 패치 12 — 화면 잠금
화면 전체를 불투명하게 가리고, 아이디 + 비밀번호로만 해제하는 오버레이입니다. 잠금 중에는 밑 화면이 보이지 않고 마우스·키보드도 통과하지 않습니다.
## 1. 새 파일 3개
| 이 프로젝트 | 리포지토리 |
| --- | --- |
| `code/UbisamBase.Core/Locking/LockService.cs` | `src/UbisamBase.Core/Locking/LockService.cs` |
| `code/UbisamBase.Core/Locking/LockOverlay.xaml` | `src/UbisamBase.Core/Locking/LockOverlay.xaml` |
| `code/UbisamBase.Core/Locking/LockOverlay.xaml.cs` | `src/UbisamBase.Core/Locking/LockOverlay.xaml.cs` |
| `code/UbisamBase.Core/Locking/LockCredentialStore.cs` | `src/UbisamBase.Core/Locking/LockCredentialStore.cs` |
| `code/UbisamBase.Core/Locking/LockSettingsView.xaml` | `src/UbisamBase.Core/Locking/LockSettingsView.xaml` |
| `code/UbisamBase.Core/Locking/LockSettingsView.xaml.cs` | `src/UbisamBase.Core/Locking/LockSettingsView.xaml.cs` |
## 2. 색 토큰 1개 추가
`Colors.Light.xaml` / `Colors.Dark.xaml`에 실패 안내판 배경을 추가했습니다 (`code/`의 두 파일에 이미 반영).
| 키 | 라이트 | 다크 |
| --- | --- | --- |
| `Ubisam.Brush.Danger.Tint` | `#12C93B3B` | `#1AF07070` |
## 3. `ShellWindow.xaml` — 오버레이 얹기
`Grid`의 **마지막 자식**으로 넣으세요. 토스트보다 뒤에 와야 잠금 화면이 토스트를 덮습니다.
```xml
xmlns:locking="clr-namespace:UbisamBase.Core.Locking"
```
```xml
<!-- 화면 잠금. 마지막 자식이라 다른 모든 것을 덮는다. -->
<locking:LockOverlay
Visibility="{Binding IsLocked, Source={x:Static locking:LockService.Current}, Converter={StaticResource BoolToVis}}"/>
</Grid>
</Window>
```
`Visibility`가 `Collapsed`면 히트 테스트도 되지 않으므로, 잠금이 아닐 때는 아래 UI가 정상 동작합니다.
## 4. 하단 탭에 잠금 버튼
`NavActionItem`을 그대로 쓰면 됩니다 — "바탕화면"과 같은 방식입니다.
```csharp
new NavActionItem("화면 잠금", Icon.Lock, () => LockService.Current.Lock());
```
`Icon` enum에 `Lock`이 없으면 추가하세요. `Themes/Icons.xaml`에는 `Ubisam.Icon.Lock`이 이미 있습니다.
## 5. 잠금 설정 화면 — 설정 탭에 등록
아이디·비밀번호를 바꾸는 화면입니다. 설정 탭의 서브탭으로 넣으세요.
```csharp
manager.AddSub<LockSettingsView, object>(ViewIds.Settings, "화면 잠금");
```
ViewModel이 없는 화면입니다(코드비하인드가 `LockCredentialStore`를 직접 다룹니다). 등록 헬퍼가 ViewModel 타입을 요구하면 빈 클래스를 하나 넘기거나, ViewModel 없이 등록하는 오버로드를 쓰세요.
**저장 방식** — `LockCredentialStore`가 처리합니다.
- 비밀번호는 평문으로 두지 않습니다: **PBKDF2 (SHA-256, 10만 회)** 해시 + 16바이트 salt
- 파일 위치: `%ProgramData%\UbisamBase\lock.json` — 사용자 계정이 아니라 장비 단위 설정이므로
- 비교는 고정 시간 비교(`FixedTimeEquals`)로 — 응답 시간으로 비밀번호를 추측할 수 없게
- 파일이 없거나 깨졌으면 기본값(`operator` / `0000`)으로 동작하고, 설정 화면 상단에 **경고 배너**가 뜹니다
- 변경에는 **현재 비밀번호 확인**이 필요합니다
`LockService.Validator`의 기본값이 이 저장소를 가리키므로 별도 배선은 필요 없습니다. 나중에 실제 계정 서비스가 생기면 델리게이트만 바꾸세요.
```csharp
LockService.Current.Validator = (id, password) => accountService.Verify(id, password);
```
### 설정 화면 검증
| 조건 | 결과 |
| --- | --- |
| 새 비밀번호 4자 미만 | Fail 토스트 |
| 새 비밀번호 ≠ 확인 | 확인 칸 아래 붉은 안내 (입력 중 실시간) |
| 허용 횟수가 1 미만 또는 숫자 아님 | Fail 토스트 |
| 현재 비밀번호 불일치 | Error 토스트, 현재 비밀번호 칸 초기화 |
| 성공 | Success 토스트, 입력칸 초기화 |
## 6. 동작
- 잠금이 걸리면 아이디 칸에 자동으로 커서가 갑니다
- Enter = 잠금 해제 (`IsDefault`)
- **Esc로는 빠져나갈 수 없습니다** — 잠금이므로 의도한 동작입니다
- 틀리면 비밀번호만 지우고 남은 시도 횟수를 안내합니다
- 3회(`MaxAttempts`) 초과하면 입력과 버튼이 비활성화됩니다. `LockService.Current.ResetAttempts()`로 관리자가 풀어야 합니다
## 7. 확인
1. 하단 탭의 잠금 버튼을 누르면 화면 전체가 가려지는지 — 상단바·탭·컨텐츠가 전혀 보이지 않아야 합니다
2. 잠금 중에 탭을 클릭해도 반응이 없는지
3. 아이디 칸에 바로 타이핑이 되는지
4. 틀린 비밀번호로 남은 횟수 안내가 뜨는지, 3회 후 비활성화되는지
5. Esc를 눌러도 풀리지 않는지
6. 다크 테마에서도 판이 어둡고 자물쇠가 accent인지
7. 잠금 중에 토스트가 위로 뜨지 않는지 (오버레이가 덮어야 함)
8. 설정 → 화면 잠금에서 비밀번호를 바꾸고, 새 비밀번호로 해제되는지
9. 처음 실행 시 설정 화면에 "기본 비밀번호 사용 중" 경고가 뜨는지
10. `%ProgramData%\UbisamBase\lock.json`에 평문 비밀번호가 없는지 (salt·hash만 있어야 합니다)
## 주의
- `ShellWindow`가 `WindowState="Maximized"` + `WindowStyle="None"`이라 오버레이가 실제로 화면 전체를 덮습니다. 창 모드로 바꾸면 창 안쪽만 가려집니다 — OS 레벨 잠금이 필요하면 별도 작업입니다.
- 잠금 중에도 `ToastService`는 계속 메시지를 큐에 넣습니다(가려서 안 보일 뿐). 잠금 중 알림을 오버레이에 보여주려면 알려주세요.
- `PasswordBox`는 `Controls.xaml`의 암시적 TextBox 스타일을 받지 못합니다(다른 타입이라서). 두 화면에서 같은 모양을 각자 정의해 두었습니다 — 입력칸 모양을 바꾸실 때는 세 곳(`Controls.xaml`, `LockOverlay.xaml`, `LockSettingsView.xaml`)을 함께 고쳐야 합니다.
- `Alt+Tab`이나 `Ctrl+Alt+Del`은 이 오버레이로 막을 수 없습니다. 그 수준의 잠금이 필요하면 키보드 훅이나 셸 대체가 필요합니다 — 요구사항이면 말씀해 주세요.

103
.design/FIX-14.md Normal file
View File

@ -0,0 +1,103 @@
# 패치 14 — 차트가 흰 판 위에 그려지는 문제
적용 화면을 보니 차트 컨트롤 자체가 흰 배경으로 렌더되고 있습니다. 여기서 세 가지 증상이 한꺼번에 나옵니다.
| 증상 | 원인 |
| --- | --- |
| 다크 테마인데 차트 판만 흰색 | `CartesianChart` / `PieChart`의 기본 배경이 흰색이고, 테마 브러시를 상속하지 않음 |
| 격자선·게이지 남은 구간이 검게 보임 | 다크용 토큰(`#383D44`)을 흰 판에 그려서 — 색은 맞지만 판이 틀림 |
| 게이지 가운데 숫자(82.4)가 안 보임 | 값 라벨이 다크용 본문색(`#E8EAED`)인데 판이 흰색이라 묻힘 |
`ChartTheme`은 축·시리즈만 칠합니다. 컨트롤의 배경은 XAML에서 지정해야 합니다.
## 1. 차트에 배경을 지정하세요
```xml
<lvc:CartesianChart Series="{Binding TrendSeries}"
XAxes="{Binding TrendXAxes}"
YAxes="{Binding TrendYAxes}"
Background="Transparent"
Height="240"/>
```
`Background="Transparent"`가 핵심입니다. `PieChart`(게이지)도 같습니다.
## 2. 판은 셸의 도면 프레임으로 감싸세요
시안(<a>11a</a>)의 차트 패널은 흰 채움이 아니라 **선 드로잉**입니다 — 1px 헤어라인 + 네 모서리 등록 마크, 배경은 컨텐츠 바탕색 그대로.
```xml
<Border BorderBrush="{DynamicResource Ubisam.Brush.Input.Border}"
BorderThickness="1"
Background="Transparent"
Padding="20,18,20,12">
<StackPanel>
<TextBlock Text="라인 트렌드"
FontFamily="{StaticResource Ubisam.Font.Heading}"
FontSize="19" FontWeight="SemiBold"
Foreground="{DynamicResource Ubisam.Brush.ContentForeground}"
Margin="0,0,0,16"/>
<lvc:CartesianChart … Background="Transparent" Height="200"/>
</StackPanel>
</Border>
```
등록 마크까지 넣으려면 `Ubisam.Style.Tag`처럼 스타일로 빼는 게 편합니다 — 필요하시면 `Ubisam.Style.BlueprintPanel`로 만들어 드립니다.
## 3. 테마를 바꾼 뒤에는 시리즈를 다시 만들어야 합니다
막대 색이 진한 파랑(`#1E7FD4`)이 아니라 밝은 파랑으로 보이는 것은, 차트가 만들어진 시점의 브러시를 그대로 들고 있기 때문입니다. `ChartTheme`은 **호출 시점**에 리소스를 읽습니다.
테마 적용 코드 끝에:
```csharp
ChartTheme.NotifyThemeChanged();
```
차트를 가진 ViewModel에서 구독해 다시 만드세요:
```csharp
public MonitorViewModel()
{
Rebuild();
ChartTheme.ThemeChanged += Rebuild;
}
private void Rebuild()
{
TrendSeries = new ISeries[] { ChartTheme.ReferenceLine(baseline), ChartTheme.Line(measured) };
TrendXAxes = new[] { ChartTheme.XAxis() };
TrendYAxes = new[] { ChartTheme.YAxis(format: "0", min: 100, max: 140) };
OnPropertyChanged(nameof(TrendSeries));
OnPropertyChanged(nameof(TrendXAxes));
OnPropertyChanged(nameof(TrendYAxes));
}
```
`ThemeChanged -= Rebuild`를 뷰가 사라질 때 해제하는 것을 잊지 마세요.
## 4. 툴팁·범례도 판을 따라야 합니다
LiveCharts2 기본 툴팁은 밝은 판입니다.
```xml
<lvc:CartesianChart TooltipBackgroundPaint="{Binding TooltipBackground}"
TooltipTextPaint="{Binding TooltipText}"
LegendTextPaint="{Binding LegendText}"
LegendPosition="Top"/>
```
## 5. 게이지에서 확인할 것
- 판이 어두워지면 남은 구간(`GridLine`)이 검정이 아니라 회색으로 읽힙니다
- 가운데 숫자가 나타납니다. 안 보이면 `DataLabelsPaint`가 `Text` 토큰인지 확인하세요
- 목표선(파선)은 LiveCharts2가 그려주지 않습니다 — 시안의 75% 틱은 차트 위에 `Path`를 얹어야 합니다. 계산식은 `FIX-10.md` §5에 있습니다
## 6. 확인
1. 다크 테마에서 차트 판이 배경과 같은 어두운 색인지
2. 격자선이 검은 실선이 아니라 헤어라인으로 보이는지
3. 막대가 진한 파랑(`#1E7FD4`)인지
4. 게이지 가운데 82.4가 보이는지
5. 라이트 테마로 바꾸고 화면을 다시 열었을 때 색이 라이트 토큰으로 바뀌는지
6. 차트 패널 테두리가 셸의 다른 판과 같은 1px 헤어라인인지

75
.design/code/APPLY.md Normal file
View File

@ -0,0 +1,75 @@
# 2a 시안 적용 — 변경 파일과 수동 편집 2곳
`Ubisam Base Platform.dc.html`의 **2a**(도면 콘솔 · 하단 5분할 탭 · 여백 없는 메인)를 실제 셸에 반영한 파일들입니다.
`code/` 아래 경로는 리포지토리의 `src/` 아래 경로와 1:1 대응합니다. 그대로 덮어쓰면 됩니다.
| 이 프로젝트의 파일 | 리포지토리 경로 |
| --- | --- |
| `code/UbisamBase.Core/Themes/Colors.Light.xaml` | `src/UbisamBase.Core/Themes/Colors.Light.xaml` |
| `code/UbisamBase.Core/Themes/Colors.Dark.xaml` | `src/UbisamBase.Core/Themes/Colors.Dark.xaml` |
| `code/UbisamBase.Core/Themes/ShellStyles.xaml` | `src/UbisamBase.Core/Themes/ShellStyles.xaml` |
| `code/UbisamBase.Core/Themes/Icons.xaml` | `src/UbisamBase.Core/Themes/Icons.xaml` (신규) |
| `code/UbisamBase.Core/Converters/IconKeyToGeometryConverter.cs` | `src/UbisamBase.Core/Converters/IconKeyToGeometryConverter.cs` (신규) |
| `code/UbisamBase.Core/Shell/ShellWindow.xaml` | `src/UbisamBase.Core/Shell/ShellWindow.xaml` |
| `code/UbisamBase.Core/Shell/ShellViewModel.cs` | `src/UbisamBase.Core/Shell/ShellViewModel.cs` |
## 무엇이 바뀌었나
**Colors.Light.xaml** — 바탕을 흰색(#FFFFFF)에서 도면 톤 밝은 회색(#F2F2F3)으로, 상단바·하단 탭 바를 흰 판으로 분리했습니다. accent는 회사 하늘색 #2196F3 그대로. 하늘색 틴트 배경(#EAF4FD 계열)은 선택 상태에만 남기고 판 전체에서는 뺐습니다.
**Colors.Dark.xaml** — 기존 값 유지, 라이트의 반전 관계만 정리(#121317 바탕 / #16171C 판 / #2A2C33 경계 / #22D3EE accent).
**ShellStyles.xaml**
- `SidebarBar.Horizontal`의 ItemsPanel을 `StackPanel` → `UniformGrid Rows="1"`로 바꿔 하단 탭이 **등록된 탭 수만큼 균등 분할**됩니다.
- 하단 탭 아이템은 아이콘+라벨 가로 배치 → **라벨만, 중앙 정렬, 선택 시 상단 3px accent 바**. 칸 사이 세로 헤어라인 추가.
- 서브탭의 알약(CornerRadius 20) → **각진 세그먼트**, ChromeButton도 CornerRadius 0.
- `Ubisam.Font.Heading` / `Ubisam.Font.Body` 폰트 리소스 추가 (Barlow → 없으면 Segoe UI 폴백).
- 빈 슬롯 표시용 `Ubisam.Brush.SlotHatch` DrawingBrush 추가.
**ShellWindow.xaml**
- 좌상단: 로고 사각형 + 회사명(`Brand`) + 구분선 + **장비명**(`Title`). 로고 이미지가 준비되면 해당 `Border`를 `<Image Source="..."/>`로 교체하세요.
- 우상단: 날짜(작게) + **시계 25px**.
- `NavList`의 기본 Dock을 `Left` → `Bottom`, Style을 Horizontal, Height 56.
- 컨텐츠 `Margin="32"` → **여백 제거**. 내부 여백은 각 모듈 View가 정합니다.
- 컨텐츠 영역에 제목줄은 두지 않습니다 — 선택된 하단 탭과 중복이라 뺐습니다.
**ShellViewModel.cs** — `Brand`(회사명, 기본 "UBISAM")와 `CurrentDate` 추가. 시계 타이머가 날짜도 같이 갱신합니다.
**Icons.xaml (신규)** — 탭 아이콘 24종을 24×24 그리드 / 스트로크 1.5 규칙으로 `Geometry` 리소스로 등록했습니다(`Ubisam.Icon.Home` … `Ubisam.Icon.Modules`). 아이콘 폰트 글리프 대신 벡터라 배율(ChromeScale)에 깨지지 않습니다.
**IconKeyToGeometryConverter.cs (신규)** — `AddMain`의 `icon` 인자를 글리프 코드가 아니라 **아이콘 이름**으로 받게 해줍니다. 등록은 이렇게:
```csharp
manager.AddMain<HomeView, HomeViewModel>(ViewIds.Home, "홈", "Home");
manager.AddMain<MonitorView, MonitorViewModel>(ViewIds.Monitor, "모니터링·그래프", "Monitor");
manager.AddMain<ReportView, ReportViewModel>(ViewIds.Report, "통계·리포트", "Chart");
manager.AddMain<MaintenanceView, MaintenanceViewModel>(ViewIds.Maintenance, "유지보수", "Maintenance");
manager.AddMain<SettingsView, SettingsViewModel>(ViewIds.Settings, "설정", "Settings");
```
`icon`을 생략하면 그 탭은 라벨만 표시됩니다. 쓸 수 있는 이름: Home, Monitor, Chart, Maintenance, Settings, Alarm, Log, Document, Data, Power, Temperature, Gauge, Run, Stop, Reset, Account, Lock, Export, Import, Search, Calendar, Clock, Link, Warning, Pass, Folder, Vision, Control, Recipe, Modules.
아이콘을 더 늘릴 때는 Lucide(https://lucide.dev)에서 같은 24px/1.5 규칙으로 가져와 `Icons.xaml`에 `Geometry` 한 줄 추가하면 끝입니다. `Icons.xaml`은 `.csproj`에 `Page`로 포함되어야 합니다(다른 Themes/*.xaml과 동일 처리).
## 직접 고쳐야 하는 2곳
1. `src/UbisamBase.Core/Shell/ShellWindow.xaml.cs` — `ApplyNavPosition` 마지막 줄의 하단 바 높이를 100 → 72로(아이콘+라벨 두 줄):
```csharp
NavList.Width = isHorizontal ? double.NaN : 140;
NavList.Height = isHorizontal ? 72 : double.NaN;
```
2. `src/UbisamBase.Core/Settings/UiSettingsService.cs` — 기본 네비게이션 위치를 하단으로:
```csharp
[ObservableProperty]
private NavPosition navPosition = NavPosition.Bottom;
```
## 확인 안 된 것
- Barlow / Barlow Condensed가 대상 PC에 설치돼 있는지 — 없으면 Segoe UI로 폴백되어 도면 느낌이 약해집니다. 설치하거나 프로젝트에 폰트를 임베드할지 알려주세요.
- 회사 로고 이미지 파일 — 아직 없어서 accent 사각형 + 첫 글자로 두었습니다.
- 빌드는 이 환경에서 돌려보지 못했습니다(WPF/.NET Framework 4.8). 빌드 후 하단 탭 정렬과 다크 전환만 눈으로 확인해 주세요.
- 아이콘 Geometry는 SVG 경로를 WPF 문법으로 옮긴 것입니다. 원·타원은 호(A) 두 개로 근사했으니, 렌더링 후 Data/Power/Account 아이콘의 곡선만 확인해 주세요.

View File

@ -0,0 +1,732 @@
# UbisamBase 셸 UI 개편 지시서
이 문서 전체를 Claude Code에 붙여넣고 "이 지시서대로 적용해줘"라고 요청하세요.
---
당신은 `UbisamBase` WPF 솔루션(.NET Framework 4.8, MVVM + CommunityToolkit.Mvvm)에서 작업합니다.
플랫폼 셸의 화면 디자인을 아래대로 교체하세요. **아래 §2의 파일 내용은 전문이 주어져 있으니 그대로 덮어쓰거나 새로 만드세요** — 임의로 재해석하거나 값을 바꾸지 마세요.
## 1. 무엇을 바꾸는가
- **좌상단**: 회사 로고(accent 사각형) + 회사명 `Brand` + 세로 구분선 + 장비명 `Title`(= `IAppSetup.GetAppName()`)
- **우상단**: 작은 날짜 + 큰 시계(25px). 기존 시계 타이머가 날짜도 함께 갱신
- **하단 탭**: 좌측 사이드바 대신 **하단 네비게이션이 기본**. 등록된 메인 탭 수만큼 `UniformGrid Rows="1"`로 균등 분할, 각 칸은 아이콘(위) + 라벨(아래), 선택 시 상단 3px accent 바 + 옅은 accent 판
- **메인 컨텐츠**: 기존 `Margin="32"` 제거 — 영역 전체를 모듈 View가 사용. 셸은 컨텐츠 제목줄을 그리지 않음(선택된 하단 탭과 중복). 모듈이 없을 때는 사선 해치 브러시로 빈 슬롯 표시
- **아이콘**: Segoe Fluent 글리프 대신 **벡터 Geometry 24종**. `AddMain`의 `icon` 인자에 글리프가 아니라 아이콘 이름("Home", "Monitor" 등)을 넘김. 생략하면 라벨만 표시
- **모양 규칙**: 라운드 제거(서브탭 알약 → 각진 세그먼트, ChromeButton CornerRadius 0), 헤어라인 경계
- **색**: 라이트 = 바탕 #F2F2F3 / 판 #FFFFFF / accent #2196F3. 다크 = 바탕 #121317 / 판 #16171C / 경계 #2A2C33 / accent #22D3EE
- **폰트**: `Ubisam.Font.Heading` = "Barlow Condensed, Segoe UI", `Ubisam.Font.Body` = "Barlow, Segoe UI" (Barlow 미설치 시 Segoe UI 폴백)
## 2. 파일 전문
### `src/UbisamBase.Core/Themes/Colors.Light.xaml`
```xml
<ResourceDictionary xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml">
<!-- 라이트: 기술 도면 톤의 밝은 바탕 + 회사 하늘색 accent -->
<SolidColorBrush x:Key="Ubisam.Brush.ContentBackground" Color="#FFF2F2F3"/>
<SolidColorBrush x:Key="Ubisam.Brush.ContentForeground" Color="#FF1D1F20"/>
<SolidColorBrush x:Key="Ubisam.Brush.Accent" Color="#FF2196F3"/>
<!-- 상단 브랜딩 바 = 흰 판, 하단 탭 바와 같은 계열 -->
<SolidColorBrush x:Key="Ubisam.Brush.Sidebar.Background" Color="#FFFFFFFF"/>
<SolidColorBrush x:Key="Ubisam.Brush.Sidebar.Foreground" Color="#FF5D5D60"/>
<SolidColorBrush x:Key="Ubisam.Brush.Sidebar.HoverBackground" Color="#14000000"/>
<SolidColorBrush x:Key="Ubisam.Brush.Sidebar.SelectedBackground" Color="#122196F3"/>
<SolidColorBrush x:Key="Ubisam.Brush.Sidebar.SelectedForeground" Color="#FF2196F3"/>
<SolidColorBrush x:Key="Ubisam.Brush.SubTabBar.Background" Color="#FFFFFFFF"/>
<SolidColorBrush x:Key="Ubisam.Brush.SubTabBar.Border" Color="#FFD6D6D9"/>
<SolidColorBrush x:Key="Ubisam.Brush.SubTabBar.Foreground" Color="#FF5D5D60"/>
<SolidColorBrush x:Key="Ubisam.Brush.SubTabBar.HoverBackground" Color="#14000000"/>
<SolidColorBrush x:Key="Ubisam.Brush.SubTabBar.SelectedBackground" Color="#FF2196F3"/>
<SolidColorBrush x:Key="Ubisam.Brush.SubTabBar.SelectedForeground" Color="#FFFFFFFF"/>
<!-- 빈 뷰 슬롯의 사선 해치 색 -->
<SolidColorBrush x:Key="Ubisam.Brush.Slot.Hatch" Color="#172196F3"/>
<SolidColorBrush x:Key="Ubisam.Brush.Muted" Color="#FF98989B"/>
</ResourceDictionary>
```
### `src/UbisamBase.Core/Themes/Colors.Dark.xaml`
```xml
<ResourceDictionary xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml">
<!-- 다크: 라이트의 반전 — 검은 바탕 + 시안 accent -->
<SolidColorBrush x:Key="Ubisam.Brush.ContentBackground" Color="#FF121317"/>
<SolidColorBrush x:Key="Ubisam.Brush.ContentForeground" Color="#FFE6E6E9"/>
<SolidColorBrush x:Key="Ubisam.Brush.Accent" Color="#FF22D3EE"/>
<SolidColorBrush x:Key="Ubisam.Brush.Sidebar.Background" Color="#FF16171C"/>
<SolidColorBrush x:Key="Ubisam.Brush.Sidebar.Foreground" Color="#FFB8BCC4"/>
<SolidColorBrush x:Key="Ubisam.Brush.Sidebar.HoverBackground" Color="#18FFFFFF"/>
<SolidColorBrush x:Key="Ubisam.Brush.Sidebar.SelectedBackground" Color="#1A22D3EE"/>
<SolidColorBrush x:Key="Ubisam.Brush.Sidebar.SelectedForeground" Color="#FF22D3EE"/>
<SolidColorBrush x:Key="Ubisam.Brush.SubTabBar.Background" Color="#FF16171C"/>
<SolidColorBrush x:Key="Ubisam.Brush.SubTabBar.Border" Color="#FF2A2C33"/>
<SolidColorBrush x:Key="Ubisam.Brush.SubTabBar.Foreground" Color="#FFB8BCC4"/>
<SolidColorBrush x:Key="Ubisam.Brush.SubTabBar.HoverBackground" Color="#18FFFFFF"/>
<SolidColorBrush x:Key="Ubisam.Brush.SubTabBar.SelectedBackground" Color="#FF22D3EE"/>
<SolidColorBrush x:Key="Ubisam.Brush.SubTabBar.SelectedForeground" Color="#FF0B0C0F"/>
<SolidColorBrush x:Key="Ubisam.Brush.Slot.Hatch" Color="#1A22D3EE"/>
<SolidColorBrush x:Key="Ubisam.Brush.Muted" Color="#FF6A6F79"/>
</ResourceDictionary>
```
### `src/UbisamBase.Core/Themes/Icons.xaml`
```xml
<!-- Ubisam Base 아이콘 세트 — 24×24 그리드, 스트로크 렌더링(StrokeThickness 1.5).
Path의 Fill은 비우고 Stroke만 쓴다. 색은 상위 Foreground를 상속시키려면
Stroke="{Binding Foreground, RelativeSource={RelativeSource AncestorType=ListBoxItem}}" 처럼 묶으면 된다.
아이콘을 추가할 때는 Lucide(https://lucide.dev)에서 같은 24px 그리드/스트로크 1.5 규칙으로 가져와
여기에 Geometry 한 줄로 등록하고, AddMain의 icon 인자에 키 뒷부분("Home" 등)을 넘긴다. -->
<ResourceDictionary xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml">
<!-- 현재 탭 5개 -->
<Geometry x:Key="Ubisam.Icon.Home">M3,10.5 L12,3 L21,10.5 L21,20 A1,1 0 0 1 20,21 L4,21 A1,1 0 0 1 3,20 Z M9.5,21 L9.5,14 L14.5,14 L14.5,21</Geometry>
<Geometry x:Key="Ubisam.Icon.Monitor">M22,12 L18,12 L15,20 L11,4 L8,12 L2,12</Geometry>
<Geometry x:Key="Ubisam.Icon.Chart">M3,3 L3,21 L21,21 M7.5,17.5 L7.5,12.5 M12,17.5 L12,8 M16.5,17.5 L16.5,9.5</Geometry>
<Geometry x:Key="Ubisam.Icon.Maintenance">M15,4 L17,4 A1,1 0 0 1 18,5 L18,20 A1,1 0 0 1 17,21 L7,21 A1,1 0 0 1 6,20 L6,5 A1,1 0 0 1 7,4 L9,4 M9,2.5 L15,2.5 L15,6.5 L9,6.5 Z M9.5,14 L11.5,16 L15.5,11.5</Geometry>
<Geometry x:Key="Ubisam.Icon.Settings">M3,7 L21,7 M3,12 L21,12 M3,17 L21,17 M13.3,7 A2.2,2.2 0 0 1 17.7,7 A2.2,2.2 0 0 1 13.3,7 Z M6.8,12 A2.2,2.2 0 0 1 11.2,12 A2.2,2.2 0 0 1 6.8,12 Z M14.8,17 A2.2,2.2 0 0 1 19.2,17 A2.2,2.2 0 0 1 14.8,17 Z</Geometry>
<!-- 예비 아이콘 — 탭이 늘어날 때 사용 -->
<Geometry x:Key="Ubisam.Icon.Alarm">M18,16.5 L18,11 A6,6 0 0 0 6,11 L6,16.5 L4.5,19.5 L19.5,19.5 Z M10,22 L14,22</Geometry>
<Geometry x:Key="Ubisam.Icon.Log">M8,6 L20,6 M8,12 L20,12 M8,18 L20,18 M3.1,6 A1.1,1.1 0 0 1 5.3,6 A1.1,1.1 0 0 1 3.1,6 Z M3.1,12 A1.1,1.1 0 0 1 5.3,12 A1.1,1.1 0 0 1 3.1,12 Z M3.1,18 A1.1,1.1 0 0 1 5.3,18 A1.1,1.1 0 0 1 3.1,18 Z</Geometry>
<Geometry x:Key="Ubisam.Icon.Document">M14,3 L7,3 A1,1 0 0 0 6,4 L6,20 A1,1 0 0 0 7,21 L17,21 A1,1 0 0 0 18,20 L18,7 Z M14,3 L14,7 L18,7 M9,12.5 L15,12.5 M9,16.5 L15,16.5</Geometry>
<Geometry x:Key="Ubisam.Icon.Data">M4,6 A8,3 0 0 1 20,6 A8,3 0 0 1 4,6 Z M4,6 L4,18 C4,19.7 7.6,21 12,21 C16.4,21 20,19.7 20,18 L20,6 M4,12 C4,13.7 7.6,15 12,15 C16.4,15 20,13.7 20,12</Geometry>
<Geometry x:Key="Ubisam.Icon.Power">M12,3 L12,12 M6.8,7.3 A8,8 0 1 0 17.2,7.3</Geometry>
<Geometry x:Key="Ubisam.Icon.Temperature">M14,14 L14,5 A2,2 0 1 0 10,5 L10,14 A4,4 0 1 0 14,14 Z</Geometry>
<Geometry x:Key="Ubisam.Icon.Gauge">M4,18 A9,9 0 1 1 20,18 M12,14 L16,10</Geometry>
<Geometry x:Key="Ubisam.Icon.Run">M8,5 L19,12 L8,19 Z</Geometry>
<Geometry x:Key="Ubisam.Icon.Stop">M6.5,6.5 L17.5,6.5 L17.5,17.5 L6.5,17.5 Z</Geometry>
<Geometry x:Key="Ubisam.Icon.Reset">M20,12 A8,8 0 1 1 16.8,5.6 M20,4.5 L20,9.5 L15,9.5</Geometry>
<Geometry x:Key="Ubisam.Icon.Account">M8,8 A4,4 0 0 1 16,8 A4,4 0 0 1 8,8 Z M4.5,21 A7.5,7.5 0 0 1 19.5,21</Geometry>
<Geometry x:Key="Ubisam.Icon.Lock">M4.5,11 L19.5,11 L19.5,21 L4.5,21 Z M8,11 L8,7.5 A4,4 0 0 1 16,7.5 L16,11</Geometry>
<Geometry x:Key="Ubisam.Icon.Export">M12,3.5 L12,14.5 M7,10 L12,14.5 L17,10 M4,20.5 L20,20.5</Geometry>
<Geometry x:Key="Ubisam.Icon.Import">M12,14.5 L12,3.5 M7,8 L12,3.5 L17,8 M4,20.5 L20,20.5</Geometry>
<Geometry x:Key="Ubisam.Icon.Search">M4,11 A7,7 0 0 1 18,11 A7,7 0 0 1 4,11 Z M16.2,16.2 L20.5,20.5</Geometry>
<Geometry x:Key="Ubisam.Icon.Calendar">M3.5,5.5 L20.5,5.5 L20.5,20.5 L3.5,20.5 Z M3.5,10.5 L20.5,10.5 M8,3 L8,7 M16,3 L16,7</Geometry>
<Geometry x:Key="Ubisam.Icon.Clock">M3,12 A9,9 0 0 1 21,12 A9,9 0 0 1 3,12 Z M12,7 L12,12.5 L15.8,14.5</Geometry>
<Geometry x:Key="Ubisam.Icon.Link">M9.5,14.5 L14.5,9.5 M13.5,6.5 L14.5,5.5 A4.6,4.6 0 0 1 21,12 L20,13 M10.5,17.5 L9.5,18.5 A4.6,4.6 0 0 1 3,12 L4,11</Geometry>
<Geometry x:Key="Ubisam.Icon.Warning">M12,4 L21,20 L3,20 Z M12,10 L12,14.5 M12,17.4 L12,17.6</Geometry>
<Geometry x:Key="Ubisam.Icon.Pass">M3,12 A9,9 0 0 1 21,12 A9,9 0 0 1 3,12 Z M8,12.5 L11,15.5 L16,9.5</Geometry>
<Geometry x:Key="Ubisam.Icon.Folder">M3.5,8 L3.5,6 A1,1 0 0 1 4.5,5 L9,5 L11,8 L19.5,8 A1,1 0 0 1 20.5,9 L20.5,19 A1,1 0 0 1 19.5,20 L4.5,20 A1,1 0 0 1 3.5,19 Z</Geometry>
<Geometry x:Key="Ubisam.Icon.Vision">M3,8 L7,8 L9,5 L15,5 L17,8 L21,8 L21,20 L3,20 Z M8.2,13.5 A3.8,3.8 0 0 1 15.8,13.5 A3.8,3.8 0 0 1 8.2,13.5 Z</Geometry>
<Geometry x:Key="Ubisam.Icon.Control">M5.5,5.5 L18.5,5.5 L18.5,18.5 L5.5,18.5 Z M9.5,9.5 L14.5,9.5 L14.5,14.5 L9.5,14.5 Z M12,2 L12,5.5 M12,18.5 L12,22 M2,12 L5.5,12 M18.5,12 L22,12</Geometry>
<Geometry x:Key="Ubisam.Icon.Recipe">M12,3.5 L3,8.5 L12,13.5 L21,8.5 Z M3,13.5 L12,18.5 L21,13.5</Geometry>
<Geometry x:Key="Ubisam.Icon.Modules">M4,4 L11,4 L11,11 L4,11 Z M13,4 L20,4 L20,11 L13,11 Z M4,13 L11,13 L11,20 L4,20 Z M13,13 L20,13 L20,20 L13,20 Z</Geometry>
</ResourceDictionary>
```
### `src/UbisamBase.Core/Themes/ShellStyles.xaml`
```xml
<ResourceDictionary xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:conv="clr-namespace:UbisamBase.Core.Converters"
xmlns:sys="clr-namespace:System;assembly=mscorlib">
<ResourceDictionary.MergedDictionaries>
<ResourceDictionary Source="pack://application:,,,/UbisamBase.Core;component/Themes/Icons.xaml"/>
</ResourceDictionary.MergedDictionaries>
<conv:BoolToVisibilityConverter x:Key="BoolToVis"/>
<conv:FirstCharacterConverter x:Key="FirstCharConverter"/>
<conv:ToastTypeToBrushConverter x:Key="ToastTypeToBrush"/>
<conv:IconKeyToGeometryConverter x:Key="IconToGeometry"/>
<!-- 타이포: Barlow가 설치돼 있으면 쓰고, 없으면 Segoe UI로 폴백 -->
<FontFamily x:Key="Ubisam.Font.Heading">Barlow Condensed, Segoe UI</FontFamily>
<FontFamily x:Key="Ubisam.Font.Body">Barlow, Segoe UI</FontFamily>
<!-- 좌/우측 네비게이션 아이템: 아이콘 위 + 라벨 아래, 선택 시 좌측 accent 바 -->
<Style x:Key="Ubisam.Style.SidebarItem" TargetType="ListBoxItem">
<Setter Property="Padding" Value="14,20"/>
<Setter Property="Foreground" Value="{DynamicResource Ubisam.Brush.Sidebar.Foreground}"/>
<Setter Property="Cursor" Value="Hand"/>
<Setter Property="HorizontalContentAlignment" Value="Center"/>
<Setter Property="Template">
<Setter.Value>
<ControlTemplate TargetType="ListBoxItem">
<Border x:Name="Bd"
Background="Transparent"
BorderThickness="3,0,0,0"
BorderBrush="Transparent"
Padding="{TemplateBinding Padding}">
<StackPanel Orientation="Vertical" HorizontalAlignment="Center">
<TextBlock Text="{Binding Icon}"
FontFamily="Segoe Fluent Icons, Segoe MDL2 Assets"
FontSize="26"
HorizontalAlignment="Center"/>
<TextBlock Text="{Binding Title}"
FontSize="13"
Margin="0,8,0,0"
HorizontalAlignment="Center"
TextWrapping="Wrap"
TextAlignment="Center"
MaxWidth="100"/>
</StackPanel>
</Border>
<ControlTemplate.Triggers>
<Trigger Property="IsSelected" Value="True">
<Setter TargetName="Bd" Property="BorderBrush" Value="{DynamicResource Ubisam.Brush.Accent}"/>
<Setter TargetName="Bd" Property="Background" Value="{DynamicResource Ubisam.Brush.Sidebar.SelectedBackground}"/>
<Setter Property="Foreground" Value="{DynamicResource Ubisam.Brush.Sidebar.SelectedForeground}"/>
<Setter Property="FontWeight" Value="SemiBold"/>
</Trigger>
<Trigger Property="IsMouseOver" Value="True">
<Setter TargetName="Bd" Property="Background" Value="{DynamicResource Ubisam.Brush.Sidebar.HoverBackground}"/>
</Trigger>
</ControlTemplate.Triggers>
</ControlTemplate>
</Setter.Value>
</Setter>
</Style>
<Style x:Key="Ubisam.Style.SidebarBar.Vertical" TargetType="ListBox">
<Setter Property="Background" Value="{DynamicResource Ubisam.Brush.Sidebar.Background}"/>
<Setter Property="BorderThickness" Value="0,0,1,0"/>
<Setter Property="BorderBrush" Value="{DynamicResource Ubisam.Brush.SubTabBar.Border}"/>
<Setter Property="Padding" Value="0,20"/>
<Setter Property="ItemContainerStyle" Value="{StaticResource Ubisam.Style.SidebarItem}"/>
<Setter Property="ScrollViewer.HorizontalScrollBarVisibility" Value="Disabled"/>
<Setter Property="ScrollViewer.VerticalScrollBarVisibility" Value="Auto"/>
<Setter Property="ItemsPanel">
<Setter.Value>
<ItemsPanelTemplate>
<StackPanel Orientation="Vertical"/>
</ItemsPanelTemplate>
</Setter.Value>
</Setter>
</Style>
<!-- 하단 네비게이션 아이템: 균등 분할 칸, 아이콘 위 + 라벨 아래.
선택 시 상단 3px accent 바 + 옅은 accent 판. icon 인자가 비면 라벨만 나온다. -->
<Style x:Key="Ubisam.Style.SidebarItem.Horizontal" TargetType="ListBoxItem">
<Setter Property="Padding" Value="10,12,10,13"/>
<Setter Property="Foreground" Value="{DynamicResource Ubisam.Brush.Sidebar.Foreground}"/>
<Setter Property="FontFamily" Value="{StaticResource Ubisam.Font.Body}"/>
<Setter Property="FontSize" Value="13.5"/>
<Setter Property="Cursor" Value="Hand"/>
<Setter Property="HorizontalContentAlignment" Value="Stretch"/>
<Setter Property="Template">
<Setter.Value>
<ControlTemplate TargetType="ListBoxItem">
<Grid>
<!-- 칸 구분선 -->
<Border BorderThickness="1,0,0,0"
BorderBrush="{DynamicResource Ubisam.Brush.SubTabBar.Border}"/>
<Border x:Name="Bd"
Background="Transparent"
BorderThickness="0,3,0,0"
BorderBrush="Transparent"
Padding="{TemplateBinding Padding}">
<StackPanel Orientation="Vertical" HorizontalAlignment="Center" VerticalAlignment="Center">
<Path Data="{Binding Icon, Converter={StaticResource IconToGeometry}}"
Stroke="{Binding Foreground, RelativeSource={RelativeSource TemplatedParent}}"
StrokeThickness="1.5"
StrokeStartLineCap="Round"
StrokeEndLineCap="Round"
StrokeLineJoin="Round"
Width="22" Height="22"
Stretch="Uniform"
Margin="0,0,0,7"
HorizontalAlignment="Center"/>
<TextBlock Text="{Binding Title}"
HorizontalAlignment="Center"
TextTrimming="CharacterEllipsis"/>
</StackPanel>
</Border>
</Grid>
<ControlTemplate.Triggers>
<!-- icon 인자를 생략한 탭은 Path를 접어 라벨만 남긴다 -->
<DataTrigger Binding="{Binding Icon, Converter={StaticResource IconToGeometry}}" Value="{x:Null}">
<Setter Property="Padding" Value="10,16,10,15"/>
</DataTrigger>
<Trigger Property="IsSelected" Value="True">
<Setter TargetName="Bd" Property="BorderBrush" Value="{DynamicResource Ubisam.Brush.Accent}"/>
<Setter TargetName="Bd" Property="Background" Value="{DynamicResource Ubisam.Brush.Sidebar.SelectedBackground}"/>
<Setter Property="Foreground" Value="{DynamicResource Ubisam.Brush.Sidebar.SelectedForeground}"/>
<Setter Property="FontWeight" Value="SemiBold"/>
</Trigger>
<Trigger Property="IsMouseOver" Value="True">
<Setter TargetName="Bd" Property="Background" Value="{DynamicResource Ubisam.Brush.Sidebar.HoverBackground}"/>
</Trigger>
</ControlTemplate.Triggers>
</ControlTemplate>
</Setter.Value>
</Setter>
</Style>
<!-- 하단 탭 바: 등록된 탭 수만큼 균등 분할(UniformGrid Rows=1) -->
<Style x:Key="Ubisam.Style.SidebarBar.Horizontal" TargetType="ListBox">
<Setter Property="Background" Value="{DynamicResource Ubisam.Brush.Sidebar.Background}"/>
<Setter Property="BorderThickness" Value="0,1,0,0"/>
<Setter Property="BorderBrush" Value="{DynamicResource Ubisam.Brush.SubTabBar.Border}"/>
<Setter Property="Padding" Value="0"/>
<Setter Property="HorizontalContentAlignment" Value="Stretch"/>
<Setter Property="ItemContainerStyle" Value="{StaticResource Ubisam.Style.SidebarItem.Horizontal}"/>
<Setter Property="ScrollViewer.HorizontalScrollBarVisibility" Value="Disabled"/>
<Setter Property="ScrollViewer.VerticalScrollBarVisibility" Value="Disabled"/>
<Setter Property="ItemsPanel">
<Setter.Value>
<ItemsPanelTemplate>
<UniformGrid Rows="1"/>
</ItemsPanelTemplate>
</Setter.Value>
</Setter>
</Style>
<!-- 하단 서브탭: 각진 세그먼트(라운드 제거) -->
<Style x:Key="Ubisam.Style.SubTabItem" TargetType="ListBoxItem">
<Setter Property="Padding" Value="22,11"/>
<Setter Property="FontSize" Value="14"/>
<Setter Property="FontFamily" Value="{StaticResource Ubisam.Font.Body}"/>
<Setter Property="Foreground" Value="{DynamicResource Ubisam.Brush.SubTabBar.Foreground}"/>
<Setter Property="Cursor" Value="Hand"/>
<Setter Property="Template">
<Setter.Value>
<ControlTemplate TargetType="ListBoxItem">
<Border x:Name="Bd"
Background="Transparent"
BorderThickness="1,0,0,0"
BorderBrush="{DynamicResource Ubisam.Brush.SubTabBar.Border}"
Padding="{TemplateBinding Padding}">
<ContentPresenter HorizontalAlignment="Center" VerticalAlignment="Center"/>
</Border>
<ControlTemplate.Triggers>
<Trigger Property="IsSelected" Value="True">
<Setter TargetName="Bd" Property="Background" Value="{DynamicResource Ubisam.Brush.SubTabBar.SelectedBackground}"/>
<Setter Property="Foreground" Value="{DynamicResource Ubisam.Brush.SubTabBar.SelectedForeground}"/>
<Setter Property="FontWeight" Value="SemiBold"/>
</Trigger>
<Trigger Property="IsMouseOver" Value="True">
<Setter TargetName="Bd" Property="Background" Value="{DynamicResource Ubisam.Brush.SubTabBar.HoverBackground}"/>
</Trigger>
</ControlTemplate.Triggers>
</ControlTemplate>
</Setter.Value>
</Setter>
</Style>
<Style x:Key="Ubisam.Style.SubTabBar" TargetType="ListBox">
<Setter Property="Background" Value="{DynamicResource Ubisam.Brush.SubTabBar.Background}"/>
<Setter Property="BorderThickness" Value="0,1,0,0"/>
<Setter Property="BorderBrush" Value="{DynamicResource Ubisam.Brush.SubTabBar.Border}"/>
<Setter Property="Padding" Value="0"/>
<Setter Property="ItemContainerStyle" Value="{StaticResource Ubisam.Style.SubTabItem}"/>
<Setter Property="ScrollViewer.HorizontalScrollBarVisibility" Value="Auto"/>
<Setter Property="ScrollViewer.VerticalScrollBarVisibility" Value="Disabled"/>
<Setter Property="ItemsPanel">
<Setter.Value>
<ItemsPanelTemplate>
<StackPanel Orientation="Horizontal"/>
</ItemsPanelTemplate>
</Setter.Value>
</Setter>
</Style>
<!-- 상단바의 계정/바탕화면/종료 같은 유틸리티 버튼: 각진 헤어라인 박스 -->
<Style x:Key="Ubisam.Style.ChromeButton" TargetType="Button">
<Setter Property="Padding" Value="14,7"/>
<Setter Property="FontSize" Value="13"/>
<Setter Property="FontFamily" Value="{StaticResource Ubisam.Font.Body}"/>
<Setter Property="Foreground" Value="{DynamicResource Ubisam.Brush.Sidebar.Foreground}"/>
<Setter Property="Cursor" Value="Hand"/>
<Setter Property="Template">
<Setter.Value>
<ControlTemplate TargetType="Button">
<Border x:Name="Bd"
Background="Transparent"
CornerRadius="0"
BorderThickness="1"
BorderBrush="{DynamicResource Ubisam.Brush.SubTabBar.Border}"
Padding="{TemplateBinding Padding}">
<ContentPresenter HorizontalAlignment="Center" VerticalAlignment="Center"/>
</Border>
<ControlTemplate.Triggers>
<Trigger Property="IsMouseOver" Value="True">
<Setter TargetName="Bd" Property="Background" Value="{DynamicResource Ubisam.Brush.Sidebar.HoverBackground}"/>
</Trigger>
</ControlTemplate.Triggers>
</ControlTemplate>
</Setter.Value>
</Setter>
</Style>
<!-- 빈 뷰 슬롯 배경(사선 해치). 모듈이 마운트되기 전 영역 표시용. -->
<DrawingBrush x:Key="Ubisam.Brush.SlotHatch"
TileMode="Tile"
Viewport="0,0,9,9"
ViewportUnits="Absolute">
<DrawingBrush.Drawing>
<GeometryDrawing>
<GeometryDrawing.Pen>
<Pen Thickness="1" Brush="{DynamicResource Ubisam.Brush.Slot.Hatch}"/>
</GeometryDrawing.Pen>
<GeometryDrawing.Geometry>
<LineGeometry StartPoint="0,9" EndPoint="9,0"/>
</GeometryDrawing.Geometry>
</GeometryDrawing>
</DrawingBrush.Drawing>
</DrawingBrush>
</ResourceDictionary>
```
### `src/UbisamBase.Core/Shell/ShellWindow.xaml`
```xml
<Window x:Class="UbisamBase.Core.Shell.ShellWindow"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:messaging="clr-namespace:UbisamBase.Core.Messaging"
Title="{Binding Title}"
Height="760" Width="1200"
WindowStartupLocation="CenterScreen"
WindowState="Maximized"
Background="{DynamicResource Ubisam.Brush.ContentBackground}">
<Window.Resources>
<ResourceDictionary>
<ResourceDictionary.MergedDictionaries>
<ResourceDictionary Source="pack://application:,,,/UbisamBase.Core;component/Themes/ShellStyles.xaml"/>
</ResourceDictionary.MergedDictionaries>
</ResourceDictionary>
</Window.Resources>
<Grid>
<DockPanel TextElement.Foreground="{DynamicResource Ubisam.Brush.ContentForeground}"
TextElement.FontFamily="{StaticResource Ubisam.Font.Body}">
<!-- 상단 브랜딩 바: 좌측 = 로고 + 회사명 + 장비명, 우측 = 날짜 + 시계, 그 뒤 유틸리티 버튼.
플랫폼 고정 요소 — 모듈이 건드리지 않는다. -->
<Border DockPanel.Dock="Top"
Background="{DynamicResource Ubisam.Brush.Sidebar.Background}"
BorderBrush="{DynamicResource Ubisam.Brush.SubTabBar.Border}"
BorderThickness="0,0,0,1"
Padding="26,14">
<Grid>
<Grid.ColumnDefinitions>
<ColumnDefinition Width="*"/>
<ColumnDefinition Width="Auto"/>
<ColumnDefinition Width="Auto"/>
<ColumnDefinition Width="Auto"/>
</Grid.ColumnDefinitions>
<StackPanel Grid.Column="0" Orientation="Horizontal" VerticalAlignment="Center">
<!-- 로고 자리: 이미지 파일이 준비되면 이 Border를 <Image Source="..."/>로 교체 -->
<Border Width="30" Height="30" Background="{DynamicResource Ubisam.Brush.Accent}">
<TextBlock Text="{Binding Brand, Converter={StaticResource FirstCharConverter}}"
Foreground="{DynamicResource Ubisam.Brush.SubTabBar.SelectedForeground}"
FontFamily="{StaticResource Ubisam.Font.Heading}"
FontWeight="SemiBold"
FontSize="17"
HorizontalAlignment="Center"
VerticalAlignment="Center"/>
</Border>
<TextBlock Text="{Binding Brand}"
FontFamily="{StaticResource Ubisam.Font.Heading}"
FontWeight="SemiBold"
FontSize="17"
VerticalAlignment="Center"
Margin="14,0,0,0">
<TextBlock.LayoutTransform>
<ScaleTransform ScaleX="1" ScaleY="1"/>
</TextBlock.LayoutTransform>
</TextBlock>
<Border Width="1" Height="20" Margin="14,0"
Background="{DynamicResource Ubisam.Brush.SubTabBar.Border}"/>
<!-- 장비명: IAppSetup.GetAppName()이 돌려주는 값 -->
<TextBlock Text="{Binding Title}"
FontSize="14.5"
Foreground="{DynamicResource Ubisam.Brush.Sidebar.Foreground}"
VerticalAlignment="Center"/>
</StackPanel>
<StackPanel Grid.Column="1" Orientation="Horizontal" VerticalAlignment="Center" Margin="0,0,20,0">
<TextBlock Text="{Binding CurrentDate}"
FontSize="11"
Foreground="{DynamicResource Ubisam.Brush.Muted}"
VerticalAlignment="Bottom"
Margin="0,0,12,2"/>
<TextBlock Text="{Binding CurrentTime}"
FontFamily="{StaticResource Ubisam.Font.Heading}"
FontWeight="SemiBold"
FontSize="25"
VerticalAlignment="Bottom"/>
</StackPanel>
<Button Grid.Column="2"
Content="{Binding Auth.UserDisplay}"
Style="{StaticResource Ubisam.Style.ChromeButton}"
VerticalAlignment="Center"
Margin="0,0,8,0"
Click="Account_Click"/>
<StackPanel Grid.Column="3" Orientation="Horizontal" VerticalAlignment="Center">
<Button Content="바탕화면"
Style="{StaticResource Ubisam.Style.ChromeButton}"
Click="ShowDesktop_Click"/>
<Button Content="종료"
Style="{StaticResource Ubisam.Style.ChromeButton}"
Margin="8,0,0,0"
Click="Exit_Click"/>
</StackPanel>
</Grid>
</Border>
<!-- 네비게이션: 기본은 하단(NavPosition.Bottom). 좌/우 전환은 코드비하인드가 처리 -->
<ListBox x:Name="NavList"
DockPanel.Dock="Bottom"
Style="{StaticResource Ubisam.Style.SidebarBar.Horizontal}"
Height="56"
ItemsSource="{Binding MainTabs}"
SelectedItem="{Binding SelectedMainTab, Mode=TwoWay}">
<ListBox.LayoutTransform>
<ScaleTransform ScaleX="{Binding UiSettings.ChromeScale}" ScaleY="{Binding UiSettings.ChromeScale}"/>
</ListBox.LayoutTransform>
</ListBox>
<!-- 컨텐츠 + 하단 서브탭. 컨텐츠는 여백 없이 영역 전체를 쓴다 —
내부 여백은 각 모듈 View가 직접 정한다. -->
<Grid>
<Grid.RowDefinitions>
<RowDefinition Height="*"/>
<RowDefinition Height="Auto"/>
</Grid.RowDefinitions>
<!-- 모듈이 마운트되기 전에는 사선 해치가 빈 슬롯을 표시 -->
<Border Grid.Row="0" Background="{DynamicResource Ubisam.Brush.SlotHatch}">
<ContentControl Content="{Binding SelectedMainTab.CurrentContent}"/>
</Border>
<ListBox Grid.Row="1"
Style="{StaticResource Ubisam.Style.SubTabBar}"
DisplayMemberPath="Title"
ItemsSource="{Binding SelectedMainTab.SubTabs}"
SelectedItem="{Binding SelectedMainTab.SelectedSubTab, Mode=TwoWay}"
Visibility="{Binding SelectedMainTab.HasSubTabs, Converter={StaticResource BoolToVis}}">
<ListBox.LayoutTransform>
<ScaleTransform ScaleX="{Binding UiSettings.ChromeScale}" ScaleY="{Binding UiSettings.ChromeScale}"/>
</ListBox.LayoutTransform>
</ListBox>
</Grid>
</DockPanel>
<!-- 토스트 오버레이 -->
<ItemsControl HorizontalAlignment="Right"
VerticalAlignment="Top"
Margin="0,80,20,0"
IsHitTestVisible="False">
<ItemsControl.ItemsSource>
<Binding Path="Messages" Source="{x:Static messaging:ToastService.Current}"/>
</ItemsControl.ItemsSource>
<ItemsControl.ItemTemplate>
<DataTemplate>
<Border Background="{Binding Type, Converter={StaticResource ToastTypeToBrush}}"
Padding="16,12"
Margin="0,0,0,8"
MaxWidth="360">
<TextBlock Text="{Binding Text}" Foreground="White" TextWrapping="Wrap" FontSize="13"/>
</Border>
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
</Grid>
</Window>
```
### `src/UbisamBase.Core/Shell/ShellViewModel.cs`
```csharp
using System;
using System.Collections.ObjectModel;
using System.Linq;
using System.Windows.Threading;
using CommunityToolkit.Mvvm.ComponentModel;
using UbisamBase.Core.Auth;
using UbisamBase.Core.Modules;
using UbisamBase.Core.Settings;
using UbisamBase.Core.Setup;
namespace UbisamBase.Core.Shell;
public partial class ShellViewModel : ObservableObject
{
/// <summary>좌상단에 표기되는 회사명. 장비명(Title)과 구분선으로 나란히 놓인다.</summary>
public string Brand { get; } = "UBISAM";
/// <summary>좌상단 장비명. IAppSetup.GetAppName()이 돌려주는 값.</summary>
public string Title { get; }
public UiSettingsService UiSettings { get; }
public AuthService Auth { get; }
public ObservableCollection<MainTabViewModel> MainTabs { get; } = new();
[ObservableProperty]
private MainTabViewModel? selectedMainTab;
[ObservableProperty]
private string currentTime = DateTime.Now.ToString("HH:mm:ss");
[ObservableProperty]
private string currentDate = FormatDate(DateTime.Now);
private readonly DispatcherTimer clockTimer;
public ShellViewModel(IServiceProvider serviceProvider, IAppSetup appSetup, TabManager tabManager, UiSettingsService uiSettings, AuthService auth)
{
Title = appSetup.GetAppName();
UiSettings = uiSettings;
Auth = auth;
foreach (var registration in tabManager.Registrations.OrderBy(m => m.Order))
{
var mainTab = new MainTabViewModel(
registration.Title,
registration.Icon,
() => registration.ContentFactory(serviceProvider),
registration.Order);
foreach (var sub in registration.SubTabs.OrderBy(s => s.Order))
{
mainTab.AddSubTab(new SubTabViewModel(sub.Title, () => sub.ContentFactory(serviceProvider), sub.Order));
}
MainTabs.Add(mainTab);
}
SelectedMainTab = MainTabs.FirstOrDefault();
clockTimer = new DispatcherTimer { Interval = TimeSpan.FromSeconds(1) };
clockTimer.Tick += (_, _) =>
{
var now = DateTime.Now;
CurrentTime = now.ToString("HH:mm:ss");
CurrentDate = FormatDate(now);
};
clockTimer.Start();
}
private static string FormatDate(DateTime value)
{
var day = value.DayOfWeek switch
{
DayOfWeek.Sunday => "일",
DayOfWeek.Monday => "월",
DayOfWeek.Tuesday => "화",
DayOfWeek.Wednesday => "수",
DayOfWeek.Thursday => "목",
DayOfWeek.Friday => "금",
_ => "토"
};
return $"{value:yyyy.MM.dd} {day}";
}
}
```
### `src/UbisamBase.Core/Converters/IconKeyToGeometryConverter.cs`
```csharp
using System;
using System.Globalization;
using System.Windows;
using System.Windows.Data;
using System.Windows.Media;
namespace UbisamBase.Core.Converters;
/// <summary>
/// MainTabViewModel.Icon 문자열("Home", "Monitor" 등)을 Themes/Icons.xaml의
/// Geometry 리소스("Ubisam.Icon.Home")로 바꿔준다.
/// 값이 비어 있거나 없는 키면 null을 돌려주고, 하단 탭은 라벨만 표시한다.
/// </summary>
public sealed class IconKeyToGeometryConverter : IValueConverter
{
public object? Convert(object value, Type targetType, object parameter, CultureInfo culture)
{
if (value is not string key || string.IsNullOrWhiteSpace(key))
{
return null;
}
var resourceKey = key.StartsWith("Ubisam.Icon.", StringComparison.Ordinal)
? key
: "Ubisam.Icon." + key;
return Application.Current?.TryFindResource(resourceKey) as Geometry;
}
public object ConvertBack(object value, Type targetType, object parameter, CultureInfo culture)
=> throw new NotSupportedException();
}
```
## 3. 직접 수정해야 하는 기존 파일 2곳
### `src/UbisamBase.Core/Shell/ShellWindow.xaml.cs`
`ApplyNavPosition`에서 하단 바 높이를 100 → 72로 바꾸세요 (아이콘 + 라벨 두 줄 높이).
```csharp
NavList.Width = isHorizontal ? double.NaN : 140;
NavList.Height = isHorizontal ? 72 : double.NaN;
```
### `src/UbisamBase.Core/Settings/UiSettingsService.cs`
기본 네비게이션 위치를 하단으로 바꾸세요.
```csharp
[ObservableProperty]
private NavPosition navPosition = NavPosition.Bottom;
```
## 4. 탭 등록 예시 (`src/UbisamBase.App/AppSetup.cs`)
`icon` 인자는 이제 아이콘 이름입니다. 기존 등록에 이름을 채워 넣으세요.
```csharp
manager.AddMain<HomeView, HomeViewModel>(ViewIds.Home, "홈", "Home");
manager.AddMain<SettingsView, SettingsViewModel>(ViewIds.Settings, "설정", "Settings");
manager.AddSub<GeneralSettingsView, GeneralSettingsViewModel>(ViewIds.Settings, "일반");
```
사용 가능한 아이콘 이름 30종: Home, Monitor, Chart, Maintenance, Settings, Alarm, Log, Document, Data, Power, Temperature, Gauge, Run, Stop, Reset, Account, Lock, Export, Import, Search, Calendar, Clock, Link, Warning, Pass, Folder, Vision, Control, Recipe, Modules.
아이콘을 더 늘릴 때는 Lucide(https://lucide.dev)에서 **24×24 그리드 / 스트로크 1.5** 규칙으로 가져와 `Icons.xaml`에 `Geometry` 한 줄로 등록하세요. SVG의 `<circle>`/`<rect>`는 WPF Geometry 문법(호 `A` 두 개 / `M…L…Z`)으로 옮겨야 합니다.
## 5. 완료 후 확인할 것
1. 빌드가 통과하는지 (`dotnet build` 또는 Visual Studio)
2. `Icons.xaml`이 빌드에 포함되는지 — SDK 스타일 csproj + `UseWPF`라 자동 포함되지만, 리소스 조회 실패 시 `Page`로 명시 추가
3. 하단 탭이 창 폭에 균등 분할되고, 선택된 탭에 상단 accent 바가 보이는지
4. 설정 화면에서 테마를 Light/Dark로 바꿀 때 상단바·하단 탭·아이콘 색이 함께 전환되는지
5. 화면 배율(ChromeScale) 슬라이더를 움직였을 때 아이콘이 깨지지 않고 확대되는지
6. 아이콘 이름을 넘기지 않은 탭이 라벨만으로 정상 표시되는지
## 6. 하지 말 것
- 모듈 View(`HomeView`, `SettingsView`, `GeneralSettingsView` 등)의 내용은 건드리지 마세요 — 셸만 바꿉니다.
- 컨텐츠 영역에 제목/헤더를 다시 넣지 마세요.
- 기존 `TabManager`, `EventBus`, `ToastService`, `AuthService`, `LogService`의 구조나 시그니처를 바꾸지 마세요. `ShellViewModel`은 `Brand`/`CurrentDate` 추가 외에는 동일합니다.

File diff suppressed because it is too large Load Diff

80
.design/code/FIX-01.md Normal file
View File

@ -0,0 +1,80 @@
# 패치 01 — 적용 후 발견된 3가지 수정
첫 적용 후 실행 화면에서 나온 문제를 고치는 패치입니다. 이 문서를 Claude Code에 붙여넣고 "이 패치 적용해줘"라고 요청하세요.
`CLAUDE_CODE_PROMPT.md`를 이미 적용한 상태를 전제로 합니다.
## 문제 1 — 하단 탭 바가 잘린다
`ShellWindow.xaml`에서 `NavList`에 `Height="56"`이 박혀 있는데, 아이콘(22) + 간격(7) + 라벨 + 상하 패딩을 합치면 56을 넘겨서 라벨이 창 밖으로 밀립니다. **높이 지정을 지우고 내용 높이에 맡깁니다.**
`ShellWindow.xaml`의 `NavList`에서 이 줄을 삭제:
```xml
Height="56"
```
그리고 `ShellWindow.xaml.cs`의 `ApplyNavPosition`도 하단일 때 높이를 강제하지 않도록:
```csharp
NavList.Width = isHorizontal ? double.NaN : 140;
NavList.Height = double.NaN;
```
## 문제 2 — 모듈이 떠 있는데도 사선 해치가 배경에 깔린다
해치는 **빈 슬롯 표시용**이라 모듈이 마운트되면 사라져야 합니다. `ShellWindow.xaml`의 컨텐츠 `Border`를 아래로 교체하세요 — `CurrentContent`가 null일 때만 해치가 됩니다.
```xml
<Border Grid.Row="0" Padding="26,22">
<Border.Style>
<Style TargetType="Border">
<Setter Property="Background" Value="{DynamicResource Ubisam.Brush.ContentBackground}"/>
<Style.Triggers>
<DataTrigger Binding="{Binding SelectedMainTab.CurrentContent}" Value="{x:Null}">
<Setter Property="Background" Value="{DynamicResource Ubisam.Brush.SlotHatch}"/>
</DataTrigger>
</Style.Triggers>
</Style>
</Border.Style>
<ContentControl Content="{Binding SelectedMainTab.CurrentContent}"/>
</Border>
```
## 문제 3 — 컨텐츠가 화면 왼쪽 끝에 붙는다
위 `Border`의 `Padding="26,22"`가 이 문제를 함께 해결합니다. 상단 헤더(`Padding="26,14"`)와 세로선이 맞습니다.
`Margin`이 아니라 `Padding`인 게 중요합니다 — 배경(해치/바탕색)은 영역 전체를 채우고, 안의 모듈 컨텐츠만 26px 안으로 들어옵니다. 모듈 View는 계속 여백 없이 작성해도 됩니다.
## 문제 4 (덤) — 아이콘 없는 탭도 아이콘 높이를 차지한다
`ShellStyles.xaml`의 `Ubisam.Style.SidebarItem.Horizontal`에서 `Path`에 `x:Name="Ico"`를 주고, 트리거를 패딩 변경이 아니라 **Path 숨김**으로 바꾸세요.
```xml
<Path x:Name="Ico"
Data="{Binding Icon, Converter={StaticResource IconToGeometry}}"
... />
```
```xml
<DataTrigger Binding="{Binding Icon, Converter={StaticResource IconToGeometry}}" Value="{x:Null}">
<Setter TargetName="Ico" Property="Visibility" Value="Collapsed"/>
</DataTrigger>
```
## 참고 — 이번 화면에서 아이콘이 안 보인 이유
`AppSetup.cs`의 `AddMain`에 `icon` 인자를 아직 넘기지 않아서 라벨만 나온 상태입니다. 정상 동작입니다. 아이콘을 켜려면:
```csharp
manager.AddMain<HomeView, HomeViewModel>(ViewIds.Home, "홈", "Home");
manager.AddMain<ToolView, ToolViewModel>(ViewIds.Tool, "도구", "Control");
manager.AddMain<SettingsView, SettingsViewModel>(ViewIds.Settings, "설정", "Settings");
```
## 확인
1. 하단 탭 바가 잘리지 않고 라벨(과 아이콘)이 온전히 보이는지
2. 설정 화면처럼 모듈이 떠 있을 때 배경이 해치가 아닌 단색인지
3. 컨텐츠 좌측이 상단 로고와 세로로 맞는지
4. 화면 배율을 1.6까지 올렸을 때 하단 탭 바가 같이 커지고 잘리지 않는지

35
.design/code/FIX-02.md Normal file
View File

@ -0,0 +1,35 @@
# 패치 02 — 하단 탭 바가 너무 넓다
하단 탭이 화면 폭을 균등 분할해서 칸이 과하게 커졌습니다. **가운데에 자기 크기만큼 모이도록** 바꿉니다
(디자인 시안 3a와 같은 방식). 바 자체는 폭 전체를 쓰고 상단 경계선도 그대로 이어집니다.
`src/UbisamBase.Core/Themes/ShellStyles.xaml` 두 곳만 고치면 됩니다.
## 1. `Ubisam.Style.SidebarBar.Horizontal`
ItemsPanel을 `UniformGrid`에서 가운데 정렬 `StackPanel`로 바꾸고, ListBox의 `HorizontalContentAlignment="Stretch"` Setter를 삭제하세요.
```xml
<Setter Property="ItemsPanel">
<Setter.Value>
<ItemsPanelTemplate>
<StackPanel Orientation="Horizontal" HorizontalAlignment="Center"/>
</ItemsPanelTemplate>
</Setter.Value>
</Setter>
```
## 2. `Ubisam.Style.SidebarItem.Horizontal`
칸 너비를 라벨 길이 + 좌우 패딩으로 정하도록, 패딩을 키우고 아이템의 `HorizontalContentAlignment="Stretch"` Setter를 삭제하세요.
```xml
<Setter Property="Padding" Value="34,12,34,13"/>
```
## 확인
- 탭 3~5개가 화면 가운데에 붙어 있고, 각 칸이 라벨 길이에 맞게 좁아졌는지
- 상단 경계선(1px)은 여전히 화면 폭 끝까지 이어지는지
- 탭이 늘어나 폭을 넘기면 좌우로 스크롤되지 않고 잘리는지 — 그런 경우 알려주세요. `ScrollViewer.HorizontalScrollBarVisibility`를 `Auto`로 되돌리면 됩니다.
- 여전히 넓게 느껴지면 패딩 34 → 24로 줄이면 됩니다.

184
.design/code/FIX-03.md Normal file
View File

@ -0,0 +1,184 @@
# 패치 03 — 하단 탭 모양 정리 + 선택 인디케이터 슬라이드 애니메이션
첨부하신 첫 번째 사진(아이콘 위 / 라벨 아래 / 라벨 아래 밑줄만) 기준으로 맞추고, 탭을 전환하면 그 밑줄이 선택한 탭으로 **미끄러져 이동**합니다. 화면 컨텐츠는 건드리지 않고 하단 탭 바에만 애니메이션이 들어갑니다.
이 패치는 패치 01·02를 포함해 대체합니다 — `ShellStyles.xaml`의 하단 탭 관련 두 스타일을 통째로 교체합니다.
## 1. 모양 (사진 1 기준)
- 칸 사이 세로 구분선 **제거**
- 선택 시 상단 3px 바 + 옅은 accent 판 → **바닥 3px 밑줄만**
- 선택 배경 틴트 **제거**, 선택된 탭은 글자·아이콘 색만 accent
- 마우스 오버는 선택되지 않은 탭에만 옅은 틴트
- 탭은 가운데 모임(균등 분할 아님), 아이템 패딩 `30,13,30,14`
## 2. `src/UbisamBase.Core/Themes/ShellStyles.xaml`
`Ubisam.Style.SidebarItem.Horizontal`과 `Ubisam.Style.SidebarBar.Horizontal`을 아래 두 스타일로 교체하세요.
`SidebarBar.Horizontal`에는 `ControlTemplate`이 새로 들어갑니다 — 그 안의 `SelectionIndicator` / `SelectionIndicatorShift` 이름을 코드비하인드가 찾습니다. **이름을 바꾸지 마세요.**
```xml
<Style x:Key="Ubisam.Style.SidebarItem.Horizontal" TargetType="ListBoxItem">
<Setter Property="Padding" Value="30,13,30,14"/>
<Setter Property="Foreground" Value="{DynamicResource Ubisam.Brush.Sidebar.Foreground}"/>
<Setter Property="FontFamily" Value="{StaticResource Ubisam.Font.Body}"/>
<Setter Property="FontSize" Value="13.5"/>
<Setter Property="FontWeight" Value="Medium"/>
<Setter Property="Cursor" Value="Hand"/>
<Setter Property="Template">
<Setter.Value>
<ControlTemplate TargetType="ListBoxItem">
<Border x:Name="Bd" Background="Transparent" Padding="{TemplateBinding Padding}">
<StackPanel Orientation="Vertical" HorizontalAlignment="Center" VerticalAlignment="Center">
<Path x:Name="Ico"
Data="{Binding Icon, Converter={StaticResource IconToGeometry}}"
Stroke="{Binding Foreground, RelativeSource={RelativeSource TemplatedParent}}"
StrokeThickness="1.5"
StrokeStartLineCap="Round" StrokeEndLineCap="Round" StrokeLineJoin="Round"
Width="22" Height="22" Stretch="Uniform"
Margin="0,0,0,7" HorizontalAlignment="Center"/>
<TextBlock Text="{Binding Title}" HorizontalAlignment="Center" TextTrimming="CharacterEllipsis"/>
</StackPanel>
</Border>
<ControlTemplate.Triggers>
<DataTrigger Binding="{Binding Icon, Converter={StaticResource IconToGeometry}}" Value="{x:Null}">
<Setter TargetName="Ico" Property="Visibility" Value="Collapsed"/>
</DataTrigger>
<Trigger Property="IsSelected" Value="True">
<Setter Property="Foreground" Value="{DynamicResource Ubisam.Brush.Sidebar.SelectedForeground}"/>
</Trigger>
<MultiTrigger>
<MultiTrigger.Conditions>
<Condition Property="IsMouseOver" Value="True"/>
<Condition Property="IsSelected" Value="False"/>
</MultiTrigger.Conditions>
<Setter TargetName="Bd" Property="Background" Value="{DynamicResource Ubisam.Brush.Sidebar.HoverBackground}"/>
</MultiTrigger>
</ControlTemplate.Triggers>
</ControlTemplate>
</Setter.Value>
</Setter>
</Style>
<Style x:Key="Ubisam.Style.SidebarBar.Horizontal" TargetType="ListBox">
<Setter Property="Background" Value="{DynamicResource Ubisam.Brush.Sidebar.Background}"/>
<Setter Property="BorderThickness" Value="0,1,0,0"/>
<Setter Property="BorderBrush" Value="{DynamicResource Ubisam.Brush.SubTabBar.Border}"/>
<Setter Property="Padding" Value="0"/>
<Setter Property="ItemContainerStyle" Value="{StaticResource Ubisam.Style.SidebarItem.Horizontal}"/>
<Setter Property="ScrollViewer.HorizontalScrollBarVisibility" Value="Disabled"/>
<Setter Property="ScrollViewer.VerticalScrollBarVisibility" Value="Disabled"/>
<Setter Property="ItemsPanel">
<Setter.Value>
<ItemsPanelTemplate>
<StackPanel Orientation="Horizontal" HorizontalAlignment="Center"/>
</ItemsPanelTemplate>
</Setter.Value>
</Setter>
<Setter Property="Template">
<Setter.Value>
<ControlTemplate TargetType="ListBox">
<Border Background="{TemplateBinding Background}"
BorderBrush="{TemplateBinding BorderBrush}"
BorderThickness="{TemplateBinding BorderThickness}">
<Grid>
<ItemsPresenter/>
<Border x:Name="SelectionIndicator"
Height="3" Width="0"
HorizontalAlignment="Left" VerticalAlignment="Bottom"
Background="{DynamicResource Ubisam.Brush.Accent}">
<Border.RenderTransform>
<TranslateTransform x:Name="SelectionIndicatorShift" X="0"/>
</Border.RenderTransform>
</Border>
</Grid>
</Border>
</ControlTemplate>
</Setter.Value>
</Setter>
</Style>
```
## 3. `src/UbisamBase.Core/Shell/ShellWindow.xaml.cs` — 애니메이션
using 추가:
```csharp
using System.Windows.Media.Animation;
```
`ShellWindow` 생성자 마지막에 이벤트 두 개를 붙이세요.
```csharp
NavList.SelectionChanged += (_, _) => MoveTabIndicator(animate: true);
NavList.Loaded += (_, _) => MoveTabIndicator(animate: false);
NavList.SizeChanged += (_, _) => MoveTabIndicator(animate: false);
```
그리고 메서드를 추가하세요.
```csharp
/// <summary>하단 탭의 선택 인디케이터를 선택된 탭 아래로 옮긴다. 좌/우 네비게이션일 때는 인디케이터가 없어 무시된다.</summary>
private void MoveTabIndicator(bool animate)
{
if (NavList.Template?.FindName("SelectionIndicator", NavList) is not Border indicator ||
NavList.Template.FindName("SelectionIndicatorShift", NavList) is not TranslateTransform shift)
{
return;
}
if (NavList.SelectedItem == null ||
NavList.ItemContainerGenerator.ContainerFromItem(NavList.SelectedItem) is not ListBoxItem container ||
!container.IsArrangeValid)
{
indicator.Width = 0;
return;
}
var origin = container.TranslatePoint(new Point(0, 0), NavList);
var targetX = origin.X;
var targetWidth = container.ActualWidth;
if (!animate)
{
shift.BeginAnimation(TranslateTransform.XProperty, null);
indicator.BeginAnimation(FrameworkElement.WidthProperty, null);
shift.X = targetX;
indicator.Width = targetWidth;
return;
}
var duration = new Duration(TimeSpan.FromMilliseconds(260));
var ease = new CubicEase { EasingMode = EasingMode.EaseOut };
shift.BeginAnimation(TranslateTransform.XProperty,
new DoubleAnimation(targetX, duration) { EasingFunction = ease, FillBehavior = FillBehavior.HoldEnd });
indicator.BeginAnimation(FrameworkElement.WidthProperty,
new DoubleAnimation(targetWidth, duration) { EasingFunction = ease, FillBehavior = FillBehavior.HoldEnd });
}
```
`ApplyNavPosition`에서 위치를 바꾼 뒤에도 한 번 호출해 주세요 (좌/우 → 하단 전환 시 위치 재계산).
```csharp
NavList.Width = isHorizontal ? double.NaN : 140;
NavList.Height = double.NaN;
if (isHorizontal)
{
Dispatcher.BeginInvoke(new Action(() => MoveTabIndicator(animate: false)));
}
```
## 4. 확인
1. 탭을 누르면 밑줄이 260ms에 걸쳐 미끄러져 오고, 너비도 새 탭 폭에 맞게 함께 변하는지
2. 앱을 처음 켰을 때는 애니메이션 없이 첫 탭 아래에 바로 놓이는지
3. 창 크기를 바꾸거나 최대화할 때 밑줄이 어긋나지 않는지
4. 설정에서 네비게이션 위치를 좌/우로 바꿨다가 하단으로 되돌렸을 때 밑줄이 정상 위치인지
5. 권한에 따라 탭이 필터링(`navView.Refresh()`)된 뒤에도 밑줄이 맞는지 — 어긋나면 `OnAuthChanged` 끝에 `MoveTabIndicator(animate: false)`를 한 줄 추가하세요
## 참고
- 260ms가 느리면 `TimeSpan.FromMilliseconds(160)`으로 줄이세요.
- 밑줄을 라벨 폭만큼만 짧게 하고 싶으면 `targetWidth`를 `container.ActualWidth - 40` 정도로 줄이고 `targetX`에 `20`을 더하면 됩니다.

74
.design/code/FIX-04.md Normal file
View File

@ -0,0 +1,74 @@
# 패치 04 — 입력 컨트롤 · 표 스타일
TextBox / ComboBox / CheckBox / Button / ListBox / ListView(GridView) 를 셸과 같은 도면 톤으로 맞춥니다.
각진 모서리 · 1px 헤어라인 · 포커스는 accent 테두리 + 옅은 글로우. 라이트/다크 모두 토큰으로 전환됩니다.
전부 **암시적 스타일**(`x:Key` 없음)이라 각 View를 고칠 필요가 없습니다 — 셸 안의 모든 해당 컨트롤에 자동 적용됩니다.
예외를 두려면 그 컨트롤에 `Style="{x:Null}"`을 주세요.
## 1. 새 파일
`src/UbisamBase.Core/Themes/Controls.xaml` — 이 프로젝트의 `code/UbisamBase.Core/Themes/Controls.xaml`을 그대로 복사하세요.
## 2. `Themes/ShellStyles.xaml`
머지 목록에 Controls.xaml을 추가하고, ComboBox 드롭다운 그림자를 정의합니다.
```xml
<ResourceDictionary.MergedDictionaries>
<ResourceDictionary Source="pack://application:,,,/UbisamBase.Core;component/Themes/Icons.xaml"/>
<ResourceDictionary Source="pack://application:,,,/UbisamBase.Core;component/Themes/Controls.xaml"/>
</ResourceDictionary.MergedDictionaries>
```
```xml
<DropShadowEffect x:Key="Ubisam.Effect.Popup" BlurRadius="12" ShadowDepth="3" Direction="270" Opacity="0.22" Color="#000000"/>
```
## 3. `Themes/Colors.Light.xaml` / `Colors.Dark.xaml`
입력·표 토큰을 추가합니다 (`code/`의 두 파일에 이미 들어 있습니다).
| 키 | 라이트 | 다크 |
| --- | --- | --- |
| `Ubisam.Brush.Input.Background` | #FFFFFF | #1B1D23 |
| `Ubisam.Brush.Input.Border` | #C9C9CC | #33363E |
| `Ubisam.Brush.Input.BorderHover` | #A8A8AC | #4A4E57 |
| `Ubisam.Brush.Input.DisabledBackground` | #EDEDEF | #191A1F |
| `Ubisam.Brush.Input.DisabledBorder` | #DEDEE1 | #26282F |
| `Ubisam.Color.AccentGlow` (Color) | #2196F3 | #22D3EE |
| `Ubisam.Brush.Table.HeaderBackground` | #EDEDEF | #1B1D23 |
| `Ubisam.Brush.Table.RowBorder` | #EDEDEF | #1F2127 |
## 4. 버튼 종류
암시적 Button은 **보조(외곽선)** 입니다. 주 동작에는 키를 지정하세요.
```xml
<Button Content="저장" Command="{Binding SaveCommand}" Style="{StaticResource Ubisam.Style.Button.Primary}"/>
<Button Content="되돌리기" Style="{StaticResource Ubisam.Style.Button.Ghost}"/>
```
`AllSettingsView.xaml`의 "저장" 버튼에 Primary를 적용하고, 거기 붙어 있는 `Padding="14,6"`은 지우세요(스타일이 정합니다).
## 5. 확인
1. `설정 → 설정값` 화면의 TextBox/ComboBox/CheckBox가 각진 모양으로 바뀌고, 클릭 시 테두리가 accent로 변하는지
2. ComboBox를 열면 드롭다운이 입력창 아래에 붙고, 선택 항목이 accent 채움인지
3. `로그` 화면 표의 헤더가 회색 판 + 1px 경계, 행 hover/선택이 accent 틴트인지
4. 다크 테마로 바꿨을 때 입력창 배경이 `#1B1D23`으로 함께 바뀌는지
5. 비활성 컨트롤이 흐리게(45%) 보이는지
## 주의
- `Controls.xaml`은 폰트 리소스를 `DynamicResource`로 참조합니다. `StaticResource`로 바꾸면 머지 시점 때문에 로드가 실패합니다.
- 암시적 `ListBox`/`ListBoxItem` 스타일이 추가되지만, 하단 탭과 서브탭 바는 `Style`/`ItemContainerStyle`을 명시적으로 지정하고 있어 영향받지 않습니다.
- `ScrollBar`와 `Slider`는 이번 패치에 없습니다 — 필요하면 알려주세요.
## 6. 아이콘 세트 갱신 (Icons.xaml 재발행)
`Themes/Icons.xaml`을 Lucide 원본 경로 기준으로 다시 만들었습니다. 이전 버전은 손으로 근사한 도형이라 실제 Lucide와 미세하게 달랐습니다 — `code/UbisamBase.Core/Themes/Icons.xaml`을 그대로 덮어쓰세요.
- 좌표계·스트로크 규칙은 동일(24×24, 1.5, 둥근 캡)해서 XAML 다른 곳은 고칠 필요가 없습니다.
- 리소스 키(`Ubisam.Icon.Home` 등)와 `AddMain`에 넘기는 이름도 그대로입니다.
- SVG 원본은 `icons/` 폴더에 있고, 각 아이콘의 Lucide 이름은 `icons/readme.md` 표에 적어뒀습니다. 새 아이콘은 그 이름으로 lucide.dev에서 찾아 추가하세요.

40
.design/code/FIX-05.md Normal file
View File

@ -0,0 +1,40 @@
# 패치 05 — 다크 테마를 검정에서 회색으로
다크 테마 바탕이 사실상 검정(#121317)이라 무겁게 보였습니다. 차가운 회색 계열로 한 단계씩 올려, 바탕 위에 판이 얹힌 층이 눈에 보이게 했습니다. accent(#22D3EE)는 그대로입니다.
`src/UbisamBase.Core/Themes/Colors.Dark.xaml` **한 파일만** 교체하면 됩니다 — 이 프로젝트의 `code/UbisamBase.Core/Themes/Colors.Dark.xaml`을 그대로 복사하세요.
## 바뀐 값
| 역할 | 기존 | 변경 |
| --- | --- | --- |
| 컨텐츠 바탕 | #121317 | **#23262B** |
| 판 (상단바 · 하단 탭 · 서브탭) | #16171C | **#2A2E34** |
| 경계 | #2A2C33 | **#3A3F47** |
| 본문 글자 | #E6E6E9 | **#E8EAED** |
| 보조 글자 | #B8BCC4 | **#C2C7CE** |
| 흐린 글자 | #6A6F79 | **#8A9099** |
| accent 위 글자 | #0B0C0F | **#12161A** |
| 입력 배경 | #1B1D23 | **#2F343A** |
| 입력 경계 | #33363E | **#454B54** |
| 입력 경계 hover | #4A4E57 | **#5A616B** |
| 입력 비활성 배경 / 경계 | #191A1F / #26282F | **#292D32 / #383D44** |
| 표 헤더 배경 | #1B1D23 | **#2F343A** |
| 표 행 경계 | #1F2127 | **#383D44** |
| hover 틴트 | #22FFFFFF | **#16FFFFFF** (바탕이 밝아진 만큼 약하게) |
바탕(#23262B) → 판(#2A2E34) → 입력(#2F343A) → 표 헤더(#2F343A) 순으로 밝아지는 3단 층입니다. 층 순서를 지키면 값은 취향대로 조정해도 구조가 유지됩니다.
## 확인
1. 다크 테마에서 상단바·하단 탭이 컨텐츠 바탕과 구분되는지(판이 살짝 밝음)
2. 입력창이 판보다 한 단계 더 밝아 눌러 넣는 자리로 읽히는지
3. 시안 글자(#22D3EE)와 회색 바탕의 대비가 충분한지 — 더 진하게 하고 싶으면 accent를 #67E8F9로 올리면 됩니다
4. 로그 표의 행 경계선이 보이는지
## 더 밝게/어둡게 하려면
세 값만 같은 방향으로 움직이면 됩니다: `ContentBackground` / `Sidebar.Background`(= `SubTabBar.Background`) / `Input.Background`. 나머지 경계·글자는 그 위에서 자동으로 읽힙니다.
- 조금 더 밝게: #282C31 / #30343B / #363B42
- 조금 더 어둡게: #1D2024 / #24282D / #292E34

108
.design/code/FIX-06.md Normal file
View File

@ -0,0 +1,108 @@
# 패치 06 — DataGrid (그리드 컨트롤)
`ListView` + `GridView`가 읽기 전용 표라면 `DataGrid`는 편집 가능한 격자입니다. 셸과 같은 도면 톤으로 맞췄습니다.
패치 04(`Controls.xaml`)를 이미 적용한 상태를 전제로 합니다.
리포지토리에 아직 `DataGrid`를 쓰는 화면이 없어서 **스타일만** 넣습니다 — 나중에 격자를 붙이면 그대로 적용됩니다.
## 모양
- 헤더: 회색 판, 세로 구분선만, 정렬된 열은 accent 글자 + accent 화살표
- 행: 짝수 행 옅은 줄무늬, hover 옅은 틴트, 선택은 accent 틴트
- 행 머리(폭 26): 선택된 행만 accent 바로 채워져 어느 행인지 왼쪽 끝에서 바로 읽힘
- 편집 중인 셀만 accent 테두리 + 내부 여백을 줄여 입력칸처럼 보임
- 행 높이 34, 헤더 높이 34, 각진 모서리 · 1px 헤어라인
## 1. 파일 교체
| 파일 | 변경 |
| --- | --- |
| `src/UbisamBase.Core/Themes/Controls.xaml` | DataGrid 스타일 6종 추가 (파일 끝) |
| `src/UbisamBase.Core/Themes/Colors.Light.xaml` | 토큰 2개 추가 |
| `src/UbisamBase.Core/Themes/Colors.Dark.xaml` | 토큰 2개 추가 |
`code/UbisamBase.Core/Themes/` 아래 세 파일을 그대로 덮어쓰세요.
새 토큰:
| 키 | 라이트 | 다크 |
| --- | --- | --- |
| `Ubisam.Brush.Table.GridLine` | #DEDEE1 | #383D44 |
| `Ubisam.Brush.Table.AltRowBackground` | #F8F8F9 | #333840 |
## 2. 쓰는 법
암시적 스타일이라 `<DataGrid>`만 놓으면 적용됩니다. 열은 직접 정의하세요 (`AutoGenerateColumns`는 False가 기본):
```xml
<DataGrid ItemsSource="{Binding Measurements}"
SelectedItem="{Binding SelectedMeasurement}">
<DataGrid.Columns>
<DataGridTextColumn Header="시각" Width="96"
Binding="{Binding Timestamp, StringFormat=HH:mm:ss}"
IsReadOnly="True"/>
<DataGridTextColumn Header="레시피" Width="*"
Binding="{Binding RecipeName}"
IsReadOnly="True"/>
<DataGridTextColumn Header="측정값" Width="118"
Binding="{Binding Value, StringFormat=N1}"
CellStyle="{StaticResource Ubisam.Style.NumericCell}"/>
<DataGridTextColumn Header="편차" Width="96"
Binding="{Binding Deviation, StringFormat=P1}"
CellStyle="{StaticResource Ubisam.Style.NumericCell}"
IsReadOnly="True"/>
</DataGrid.Columns>
</DataGrid>
```
수치 열에는 `CellStyle="{StaticResource Ubisam.Style.NumericCell}"`을 주세요 — 우측 정렬됩니다.
편집을 아예 막을 화면은 `IsReadOnly="True"`를 `DataGrid`에 주면 됩니다.
## 3. 상태 열은 태그로
판정·레벨 같은 상태 열은 맨 텍스트로 두지 마세요 — 예외 행(재검, Warn)이 정상 행과 구분되지 않습니다. 태그 스타일을 쓰세요.
```xml
<DataGridTemplateColumn Header="판정" Width="96">
<DataGridTemplateColumn.CellTemplate>
<DataTemplate>
<Border Style="{StaticResource Ubisam.Style.Tag.Accent}">
<TextBlock Text="{Binding Verdict}" Style="{StaticResource Ubisam.Style.TagText.Accent}"/>
</Border>
</DataTemplate>
</DataGridTemplateColumn.CellTemplate>
</DataGridTemplateColumn>
```
세 종류입니다:
| 스타일 쌍 | 쓰는 곳 |
| --- | --- |
| `Ubisam.Style.Tag.Accent` + `TagText.Accent` | 정상·완료 (양품, Info) |
| `Ubisam.Style.Tag.Outline` + `TagText.Outline` | 주의·예외 (재검, Warn) — 외곽선만이라 눈에 걸립니다 |
| `Ubisam.Style.Tag` + `TagText` | 중립·대기 (—, Draft) |
상태값에 따라 스타일을 바꿔야 하면 `DataTrigger`로 `Border.Style`을 전환하거나, 값→스타일 컨버터를 하나 두세요.
새 토큰 4개(`Ubisam.Brush.Tag.*`)가 두 Colors 파일에 추가됐습니다.
## 4. 합계행
시안의 맨 아래 합계행은 `DataGrid`가 기본 제공하지 않습니다. 격자 아래에 `Grid` 한 줄을 따로 놓고 열 폭을 맞추거나, 필요하면 `DataGrid` 안에서 열 폭을 공유하는 방식으로 만들어야 합니다 — 어느 쪽으로 할지 알려주시면 그 부분만 따로 만들어 드립니다.
## 5. 확인
1. 헤더를 클릭해 정렬하면 그 열 글자가 accent로 바뀌고 화살표 방향이 뒤집히는지
2. 행을 선택하면 왼쪽 끝 행 머리가 accent 바로 채워지는지
3. 셀을 더블클릭해 편집에 들어가면 그 셀만 accent 테두리가 생기는지
4. 수치 열(측정값·편차)이 모두 우측 정렬인지 — 한쪽만 좌측이면 `CellStyle`이 빠진 것입니다
5. 판정 열의 재검 행이 양품 행과 한눈에 구분되는지
4. 짝수 행 줄무늬가 다크에서도 구분되는지(#333840)
5. 열 경계를 드래그해 폭 조절이 되는지 (그리퍼는 투명하지만 커서가 바뀝니다)
## 주의
- `DevExpress GridControl`이 아니라 **WPF 기본 `DataGrid`** 기준입니다. DevExpress를 쓰신다면 알려주세요 — 스타일 구조가 완전히 다릅니다.
- 줄무늬가 부담스러우면 `DataGrid`의 `AlternatingRowBackground`를 `Transparent`로 바꾸면 됩니다.
- 행을 더 밀집하려면 `RowHeight`를 34 → 28로, 셀 `Padding`을 `11,9` → `9,5`로 줄이세요.

50
.design/code/FIX-07.md Normal file
View File

@ -0,0 +1,50 @@
# 패치 07 — 다크모드 accent를 회사 하늘색으로
다크에서 시안(#22D3EE)이 회사 색과 동떨어져 보이고, 밝은 accent 위에 검은 글자를 얹는 조합이 어색했습니다. 회사 하늘색 계열로 바꾸고 **accent를 두 단계로 나눕니다.**
| 역할 | 다크 | 라이트 | 쓰는 곳 |
| --- | --- | --- | --- |
| `Ubisam.Brush.Accent` | **#4DA6F5** | #2196F3 | 선 · 글자 · 탭 밑줄 · 포커스 테두리 — 바탕 위에 얹히는 것 |
| `Ubisam.Brush.Accent.Fill` | **#1E7FD4** | #1D82E8 | 글자를 얹는 채움면 — 주 버튼, 로고 타일, 선택된 서브탭·항목, 체크박스 |
| `Ubisam.Brush.Accent.OnFill` | **#F2F7FC** | #FFFFFF | 그 채움면 위의 글자 · 체크 표시 |
밝은 단계(#4DA6F5)에 검은 글자를 얹지 않는 것이 규칙입니다 — 흰 글자와의 대비가 부족하므로, 글자가 올라가는 면은 깊은 단계(#1E7FD4, 흰 글자와 약 4.2:1)를 씁니다.
라이트에도 같은 두 키를 추가했습니다 — 스타일이 양쪽 테마에서 같은 이름으로 해석됩니다. 라이트의 기존 색은 그대로입니다.
## 파일 교체
`code/UbisamBase.Core/Themes/` 아래 세 파일을 덮어쓰세요.
| 파일 | 변경 |
| --- | --- |
| `Colors.Dark.xaml` | accent 계열 전체 교체 (틴트 · 글로우 · 해치 · 태그 포함) + 새 키 2개 |
| `Colors.Light.xaml` | 새 키 2개 추가 |
| `Controls.xaml` | 채움면을 쓰는 4곳이 `Accent.Fill` / `Accent.OnFill`을 참조 |
| `Shell/ShellWindow.xaml` | 로고 타일이 `Accent.Fill` / `Accent.OnFill`을 참조 |
## 로고 — ShellWindow.xaml도 함께 바꿔야 합니다
상단바 로고 타일이 `Accent`(밝은 단계)에 묶여 있어 다크에서 흰 글자와 대비가 부족했습니다. 두 속성을 채움면 키로 바꾸세요 (`code/UbisamBase.Core/Shell/ShellWindow.xaml`에 이미 반영돼 있습니다).
```xml
<Border Width="30" Height="30" Background="{DynamicResource Ubisam.Brush.Accent.Fill}">
<TextBlock Text="{Binding Brand, Converter={StaticResource FirstCharConverter}}"
Foreground="{DynamicResource Ubisam.Brush.Accent.OnFill}"
... />
</Border>
```
`ShellStyles.xaml`의 다른 두 곳(좌측 네비 선택 테두리, 하단 탭 인디케이터 바)은 글자를 얹지 않는 선이라 `Accent`가 맞습니다 — 그대로 두세요.
## 로고 이미지
다크 상단바의 로고 타일도 #1E7FD4 + 흰 마크가 됩니다 — 원본 로고에 더 가깝습니다. `logo/ubisam-logo-mono.svg`는 타일이 `currentColor`이므로 부모에 `color: #1E7FD4`만 주면 됩니다.
## 확인
1. 다크에서 주 버튼("저장")이 진한 파랑 + 흰 글자인지
2. 탭 밑줄 · 시계 · 포커스 테두리는 밝은 파랑(#4DA6F5)인지
3. 체크박스 체크 표시가 흰색인지 — 검정이면 `Controls.xaml`이 갱신되지 않은 것입니다
4. 선택된 서브탭 글자가 읽히는지
5. 라이트 테마가 이전과 동일한지

39
.design/code/FIX-08.md Normal file
View File

@ -0,0 +1,39 @@
# 패치 08 — Slider · ScrollBar + 적용 확인
## 먼저 확인: Controls.xaml이 빌드에 들어갔는지
다크모드에서 콤보박스가 **둥근 모서리 + 밝은 회색**으로 보이면 WPF 기본 크롬입니다. 패치 04의 스타일이 적용되면 **각진 모서리 + #2F343A 어두운 배경**이 됩니다. 두 가지를 확인하세요.
1. `src/UbisamBase.Core/Themes/Controls.xaml` 파일이 존재하는지
2. `Themes/ShellStyles.xaml` 상단에 머지 항목이 있는지
```xml
<ResourceDictionary.MergedDictionaries>
<ResourceDictionary Source="pack://application:,,,/UbisamBase.Core;component/Themes/Icons.xaml"/>
<ResourceDictionary Source="pack://application:,,,/UbisamBase.Core;component/Themes/Controls.xaml"/>
</ResourceDictionary.MergedDictionaries>
```
머지가 있는데도 기본 크롬이 나오면, 그 `UserControl`이 `ShellWindow.Resources`를 상속받지 못하는 경우입니다 — 그때는 `App.xaml`의 `Application.Resources`에 같은 머지를 넣으세요. 암시적 스타일은 리소스 조회 범위 안에서만 걸립니다.
## Slider
UI 설정 화면의 "화면 배율" 슬라이더가 기본 모양이었습니다. 트랙은 얇은 홈, 지나온 구간만 accent, 썸은 각진 10×20 세로 막대(둥근 다이얼이 아니라 계기 슬라이드)로 맞췄습니다.
## ScrollBar
폭 10의 얇은 막대. 트랙은 비우고 썸만 회색(#5A616B), hover·드래그 시 accent. 위아래 화살표 버튼은 없앴습니다.
두 스타일 모두 암시적이라 기존 화면을 고칠 필요는 없습니다.
## 파일
`code/UbisamBase.Core/Themes/Controls.xaml` 하나만 덮어쓰면 됩니다 (기존 내용 + Slider·ScrollBar 추가).
## 확인
1. 콤보박스가 어두운 배경 + 각진 모서리인지 (아니면 위 머지 문제)
2. 화면 배율 슬라이더의 왼쪽 구간만 파랗게 차는지
3. 썸을 잡고 끌 때 색이 밝아지는지
4. 목록·표를 스크롤할 때 막대가 얇고 어두운지
5. 슬라이더를 비활성화하면 45%로 흐려지는지

65
.design/code/FIX-09.md Normal file
View File

@ -0,0 +1,65 @@
# 패치 09 — MessageBox를 테마 적용 대화상자로
WPF의 `MessageBox`는 OS 크롬이라 스타일을 입힐 수 없습니다. 다크모드에서 혼자 밝은 창이 뜨는 이유입니다. 같은 모양의 커스텀 창으로 대체합니다.
**`MessageUtil`의 시그니처는 그대로입니다** — `ShowInfo`, `ShowError`, `ShowYesNo`, `ShowSaveSuccessDialog`, `ShowSaveFailedDialog`, Toast 5종 전부 동일합니다. 호출하는 쪽은 한 줄도 고칠 필요가 없습니다.
## 모양
- OS 타이틀바 없음(`WindowStyle="None"`) — 제목줄을 직접 그리고 드래그로 옮길 수 있음
- 폭 420 고정, 높이는 내용에 맞춰 자동
- 셸과 같은 3단 구성: 제목줄(판) / 본문(바탕) / 버튼줄(판), 사이는 1px 헤어라인
- 본문은 아이콘 24px + 메시지, 아이콘은 상단 정렬(메시지가 길어도 위에 고정)
- 버튼은 우측 하단. 확인/예가 주 동작(`Accent.Fill` 채움), 아니오가 보조(외곽선)
- 아이콘: 알림 `circle-info`, 오류 `circle-x`(붉은색), 확인 `circle-help`
- Enter = 확인, Esc = 취소
## 1. 새 파일 3개
| 이 프로젝트 | 리포지토리 |
| --- | --- |
| `code/UbisamBase.Core/Messaging/MessageDialog.xaml` | `src/UbisamBase.Core/Messaging/MessageDialog.xaml` (신규) |
| `code/UbisamBase.Core/Messaging/MessageDialog.xaml.cs` | `src/UbisamBase.Core/Messaging/MessageDialog.xaml.cs` (신규) |
| `code/UbisamBase.Core/Messaging/MessageUtil.cs` | `src/UbisamBase.Core/Messaging/MessageUtil.cs` (교체) |
## 2. 갱신 파일 3개
| 파일 | 변경 |
| --- | --- |
| `Themes/Icons.xaml` | 아이콘 3종 추가 — `Ubisam.Icon.Info` / `.Question` / `.Error` |
| `Themes/Colors.Light.xaml` | 오류색 토큰 2개 — `Danger` #C93B3B / `Danger.Fill` #B32F2F |
| `Themes/Colors.Dark.xaml` | 오류색 토큰 2개 — `Danger` #F07070 / `Danger.Fill` #B33A3A |
붉은색은 대화상자 아이콘과 실패 표시에만 씁니다 — 장식용 색을 늘리지 않는 것이 이 시스템의 규칙입니다.
## 3. 쓰는 법 (변화 없음)
```csharp
MessageUtil.ShowInfo("저장", "저장되었습니다.");
MessageUtil.ShowError("저장 실패", "저장하지 못했습니다.");
if (MessageUtil.ShowYesNo("확인", "저장하지 않은 변경 사항이 있습니다.\n그래도 나가시겠습니까?"))
{
// 예를 눌렀을 때
}
MessageUtil.ShowSavedToast(logger);
```
여러 줄 메시지는 `\n`을 그대로 쓰면 됩니다 — `TextWrapping="Wrap"`이라 폭을 넘기면 자동으로도 접힙니다.
## 4. 확인
1. `저장` 버튼을 눌러 대화상자가 뜰 때 OS 타이틀바가 없고 셸과 같은 각진 프레임인지
2. 다크 테마에서 대화상자도 어두운지 (밝은 창이 뜨면 파일이 교체되지 않은 것)
3. 제목줄을 끌어 창이 움직이는지
4. Enter로 확인, Esc로 취소가 되는지
5. 오류 대화상자의 아이콘만 붉은색이고, 버튼은 accent인지
6. 확인(YesNo) 대화상자에서 "아니오"가 보이고 false를 돌려주는지
7. 부모 창 가운데에 뜨는지
## 주의
- `MessageDialog`는 `ShellStyles.xaml`을 자체 `Window.Resources`에 머지합니다 — 별도 창이라 셸의 리소스를 상속받지 못하기 때문입니다. 이 줄을 지우면 스타일이 빠집니다.
- 작업 표시줄에 안 뜨게 `ShowInTaskbar="False"`로 두었습니다.
- 부팅 초기(`MainWindow`가 아직 로드되지 않은 시점)에 호출하면 화면 가운데에 뜹니다.

View File

@ -0,0 +1,32 @@
using System;
using System.Globalization;
using System.Windows;
using System.Windows.Data;
using System.Windows.Media;
namespace UbisamBase.Core.Converters;
/// <summary>
/// MainTabViewModel.Icon 문자열("Home", "Monitor" 등)을 Themes/Icons.xaml의
/// Geometry 리소스("Ubisam.Icon.Home")로 바꿔준다.
/// 값이 비어 있거나 없는 키면 null을 돌려주고, 하단 탭은 라벨만 표시한다.
/// </summary>
public sealed class IconKeyToGeometryConverter : IValueConverter
{
public object? Convert(object value, Type targetType, object parameter, CultureInfo culture)
{
if (value is not string key || string.IsNullOrWhiteSpace(key))
{
return null;
}
var resourceKey = key.StartsWith("Ubisam.Icon.", StringComparison.Ordinal)
? key
: "Ubisam.Icon." + key;
return Application.Current?.TryFindResource(resourceKey) as Geometry;
}
public object ConvertBack(object value, Type targetType, object parameter, CultureInfo culture)
=> throw new NotSupportedException();
}

View File

@ -0,0 +1,96 @@
<Window x:Class="UbisamBase.Core.Messaging.MessageDialog"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
WindowStyle="None"
ResizeMode="NoResize"
SizeToContent="Height"
Width="420"
MinHeight="0"
ShowInTaskbar="False"
WindowStartupLocation="CenterOwner"
AllowsTransparency="False"
Background="{DynamicResource Ubisam.Brush.ContentBackground}"
KeyDown="Dialog_KeyDown">
<Window.Resources>
<ResourceDictionary>
<ResourceDictionary.MergedDictionaries>
<ResourceDictionary Source="pack://application:,,,/UbisamBase.Core;component/Themes/ShellStyles.xaml"/>
</ResourceDictionary.MergedDictionaries>
</ResourceDictionary>
</Window.Resources>
<!-- OS 타이틀바 대신 1px 헤어라인 프레임. 셸과 같은 각진 도면 톤. -->
<Border BorderBrush="{DynamicResource Ubisam.Brush.Input.Border}"
BorderThickness="1"
TextElement.Foreground="{DynamicResource Ubisam.Brush.ContentForeground}"
TextElement.FontFamily="{StaticResource Ubisam.Font.Body}">
<Grid>
<Grid.RowDefinitions>
<RowDefinition Height="Auto"/>
<RowDefinition Height="Auto"/>
<RowDefinition Height="Auto"/>
</Grid.RowDefinitions>
<!-- 제목줄: 드래그로 창을 옮길 수 있다 -->
<Border Grid.Row="0"
Background="{DynamicResource Ubisam.Brush.Sidebar.Background}"
BorderBrush="{DynamicResource Ubisam.Brush.SubTabBar.Border}"
BorderThickness="0,0,0,1"
Padding="20,14"
MouseLeftButtonDown="TitleBar_MouseLeftButtonDown">
<TextBlock x:Name="TitleText"
Text="{Binding Title, RelativeSource={RelativeSource AncestorType=Window}}"
FontFamily="{StaticResource Ubisam.Font.Heading}"
FontSize="18"
FontWeight="SemiBold"/>
</Border>
<!-- 본문: 아이콘 + 메시지 -->
<Grid Grid.Row="1" Margin="20,22">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="Auto"/>
<ColumnDefinition Width="*"/>
</Grid.ColumnDefinitions>
<Path x:Name="Icon"
Grid.Column="0"
Width="24" Height="24"
Margin="0,1,15,0"
Stretch="Uniform"
StrokeThickness="1.5"
StrokeStartLineCap="Round"
StrokeEndLineCap="Round"
StrokeLineJoin="Round"
VerticalAlignment="Top"/>
<TextBlock x:Name="MessageText"
Grid.Column="1"
FontSize="13.5"
LineHeight="21"
TextWrapping="Wrap"
VerticalAlignment="Center"/>
</Grid>
<!-- 버튼: 우측 하단. 확인이 주 동작, 취소가 보조. -->
<Border Grid.Row="2"
Background="{DynamicResource Ubisam.Brush.Sidebar.Background}"
BorderBrush="{DynamicResource Ubisam.Brush.SubTabBar.Border}"
BorderThickness="0,1,0,0"
Padding="20,14">
<StackPanel Orientation="Horizontal" HorizontalAlignment="Right">
<Button x:Name="CancelButton"
Content="취소"
MinWidth="86"
Visibility="Collapsed"
Margin="0,0,9,0"
Click="Cancel_Click"/>
<Button x:Name="OkButton"
Content="확인"
MinWidth="86"
IsDefault="True"
Style="{StaticResource Ubisam.Style.Button.Primary}"
Click="Ok_Click"/>
</StackPanel>
</Border>
</Grid>
</Border>
</Window>

View File

@ -0,0 +1,81 @@
using System.Windows;
using System.Windows.Input;
using System.Windows.Media;
namespace UbisamBase.Core.Messaging;
/// <summary>
/// OS 기본 MessageBox 대신 쓰는 테마 적용 대화상자.
/// 직접 생성하지 말고 <see cref="MessageUtil"/>의 Info / Error / Confirm을 쓴다.
/// </summary>
public partial class MessageDialog : Window
{
public enum DialogKind { Info, Error, Confirm }
private MessageDialog(string title, string message, DialogKind kind)
{
InitializeComponent();
Title = title;
MessageText.Text = message;
// 아이콘과 색은 종류에 따라. 오류만 붉은색을 쓰고 나머지는 accent.
var iconKey = kind switch
{
DialogKind.Error => "Ubisam.Icon.Error",
DialogKind.Confirm => "Ubisam.Icon.Question",
_ => "Ubisam.Icon.Info"
};
var brushKey = kind == DialogKind.Error ? "Ubisam.Brush.Danger" : "Ubisam.Brush.Accent";
Icon.Data = TryFindResource(iconKey) as Geometry;
Icon.Stroke = TryFindResource(brushKey) as Brush;
if (kind == DialogKind.Confirm)
{
OkButton.Content = "예";
CancelButton.Content = "아니오";
CancelButton.Visibility = Visibility.Visible;
CancelButton.IsCancel = true;
}
// 소유 창 위에 가운데 정렬. 소유자가 없으면 화면 가운데.
var owner = Application.Current?.MainWindow;
if (owner != null && owner.IsLoaded && !ReferenceEquals(owner, this))
{
Owner = owner;
WindowStartupLocation = WindowStartupLocation.CenterOwner;
}
else
{
WindowStartupLocation = WindowStartupLocation.CenterScreen;
}
}
/// <summary>확인 버튼을 눌렀으면 true. Confirm이 아닌 경우 항상 true.</summary>
public static bool Show(string title, string message, DialogKind kind)
{
var dialog = new MessageDialog(title, message, kind);
return dialog.ShowDialog() == true;
}
private void TitleBar_MouseLeftButtonDown(object sender, MouseButtonEventArgs e)
{
if (e.ButtonState == MouseButtonState.Pressed)
{
DragMove();
}
}
private void Dialog_KeyDown(object sender, KeyEventArgs e)
{
if (e.Key == Key.Escape)
{
DialogResult = false;
}
}
private void Ok_Click(object sender, RoutedEventArgs e) => DialogResult = true;
private void Cancel_Click(object sender, RoutedEventArgs e) => DialogResult = false;
}

View File

@ -0,0 +1,50 @@
using UbisamBase.Core.Logging;
namespace UbisamBase.Core.Messaging;
/// <summary>
/// 어디서나 바로 쓰는 대화상자 / Toast 헬퍼.
/// 시그니처는 이전과 완전히 동일하고, 대화상자만 OS 기본 MessageBox에서
/// 테마가 적용된 <see cref="MessageDialog"/>로 바뀌었다 — 호출하는 쪽은 고칠 것이 없다.
/// </summary>
public static class MessageUtil
{
public static void ShowInfo(string title, string message)
=> MessageDialog.Show(title, message, MessageDialog.DialogKind.Info);
public static void ShowError(string title, string message)
=> MessageDialog.Show(title, message, MessageDialog.DialogKind.Error);
public static bool ShowYesNo(string title, string message)
=> MessageDialog.Show(title, message, MessageDialog.DialogKind.Confirm);
public static void ShowSaveSuccessDialog() => ShowInfo("저장", "저장되었습니다.");
public static void ShowSaveFailedDialog() => ShowError("저장 실패", "저장하지 못했습니다.");
public static void ShowToast(string message, Logger? logger = null)
{
ToastService.Current.Show(message, ToastType.Info);
logger?.I(message);
}
public static void ShowSuccessToast(string message = "완료되었습니다.", Logger? logger = null)
{
ToastService.Current.Show(message, ToastType.Success);
logger?.I(message);
}
public static void ShowSavedToast(Logger? logger = null) => ShowSuccessToast("저장되었습니다.", logger);
public static void ShowWarningToast(string message, Logger? logger = null)
{
ToastService.Current.Show(message, ToastType.Warning);
logger?.W(message);
}
public static void ShowErrorToast(string message, Logger? logger = null)
{
ToastService.Current.Show(message, ToastType.Error);
logger?.E(message);
}
}

View File

@ -0,0 +1,87 @@
using System;
using System.Collections.ObjectModel;
using System.Linq;
using System.Windows.Threading;
using CommunityToolkit.Mvvm.ComponentModel;
using UbisamBase.Core.Auth;
using UbisamBase.Core.Modules;
using UbisamBase.Core.Settings;
using UbisamBase.Core.Setup;
namespace UbisamBase.Core.Shell;
public partial class ShellViewModel : ObservableObject
{
/// <summary>좌상단에 표기되는 회사명. 장비명(Title)과 구분선으로 나란히 놓인다.</summary>
public string Brand { get; } = "UBISAM";
/// <summary>좌상단 장비명. IAppSetup.GetAppName()이 돌려주는 값.</summary>
public string Title { get; }
public UiSettingsService UiSettings { get; }
public AuthService Auth { get; }
public ObservableCollection<MainTabViewModel> MainTabs { get; } = new();
[ObservableProperty]
private MainTabViewModel? selectedMainTab;
[ObservableProperty]
private string currentTime = DateTime.Now.ToString("HH:mm:ss");
[ObservableProperty]
private string currentDate = FormatDate(DateTime.Now);
private readonly DispatcherTimer clockTimer;
public ShellViewModel(IServiceProvider serviceProvider, IAppSetup appSetup, TabManager tabManager, UiSettingsService uiSettings, AuthService auth)
{
Title = appSetup.GetAppName();
UiSettings = uiSettings;
Auth = auth;
foreach (var registration in tabManager.Registrations.OrderBy(m => m.Order))
{
var mainTab = new MainTabViewModel(
registration.Title,
registration.Icon,
() => registration.ContentFactory(serviceProvider),
registration.Order);
foreach (var sub in registration.SubTabs.OrderBy(s => s.Order))
{
mainTab.AddSubTab(new SubTabViewModel(sub.Title, () => sub.ContentFactory(serviceProvider), sub.Order));
}
MainTabs.Add(mainTab);
}
SelectedMainTab = MainTabs.FirstOrDefault();
clockTimer = new DispatcherTimer { Interval = TimeSpan.FromSeconds(1) };
clockTimer.Tick += (_, _) =>
{
var now = DateTime.Now;
CurrentTime = now.ToString("HH:mm:ss");
CurrentDate = FormatDate(now);
};
clockTimer.Start();
}
private static string FormatDate(DateTime value)
{
var day = value.DayOfWeek switch
{
DayOfWeek.Sunday => "일",
DayOfWeek.Monday => "월",
DayOfWeek.Tuesday => "화",
DayOfWeek.Wednesday => "수",
DayOfWeek.Thursday => "목",
DayOfWeek.Friday => "금",
_ => "토"
};
return $"{value:yyyy.MM.dd} {day}";
}
}

View File

@ -0,0 +1,172 @@
<Window x:Class="UbisamBase.Core.Shell.ShellWindow"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:messaging="clr-namespace:UbisamBase.Core.Messaging"
Title="{Binding Title}"
Height="760" Width="1200"
WindowStartupLocation="CenterScreen"
WindowState="Maximized"
Background="{DynamicResource Ubisam.Brush.ContentBackground}">
<Window.Resources>
<ResourceDictionary>
<ResourceDictionary.MergedDictionaries>
<ResourceDictionary Source="pack://application:,,,/UbisamBase.Core;component/Themes/ShellStyles.xaml"/>
</ResourceDictionary.MergedDictionaries>
</ResourceDictionary>
</Window.Resources>
<Grid>
<DockPanel TextElement.Foreground="{DynamicResource Ubisam.Brush.ContentForeground}"
TextElement.FontFamily="{StaticResource Ubisam.Font.Body}">
<!-- 상단 브랜딩 바: 좌측 = 로고 + 회사명 + 장비명, 우측 = 날짜 + 시계, 그 뒤 유틸리티 버튼.
플랫폼 고정 요소 — 모듈이 건드리지 않는다. -->
<Border DockPanel.Dock="Top"
Background="{DynamicResource Ubisam.Brush.Sidebar.Background}"
BorderBrush="{DynamicResource Ubisam.Brush.SubTabBar.Border}"
BorderThickness="0,0,0,1"
Padding="26,14">
<Grid>
<Grid.ColumnDefinitions>
<ColumnDefinition Width="*"/>
<ColumnDefinition Width="Auto"/>
<ColumnDefinition Width="Auto"/>
<ColumnDefinition Width="Auto"/>
</Grid.ColumnDefinitions>
<StackPanel Grid.Column="0" Orientation="Horizontal" VerticalAlignment="Center">
<!-- 로고 자리: 이미지 파일이 준비되면 이 Border를 <Image Source="..."/>로 교체 -->
<Border Width="30" Height="30" Background="{DynamicResource Ubisam.Brush.Accent.Fill}">
<TextBlock Text="{Binding Brand, Converter={StaticResource FirstCharConverter}}"
Foreground="{DynamicResource Ubisam.Brush.Accent.OnFill}"
FontFamily="{StaticResource Ubisam.Font.Heading}"
FontWeight="SemiBold"
FontSize="17"
HorizontalAlignment="Center"
VerticalAlignment="Center"/>
</Border>
<TextBlock Text="{Binding Brand}"
FontFamily="{StaticResource Ubisam.Font.Heading}"
FontWeight="SemiBold"
FontSize="17"
VerticalAlignment="Center"
Margin="14,0,0,0">
<TextBlock.LayoutTransform>
<ScaleTransform ScaleX="1" ScaleY="1"/>
</TextBlock.LayoutTransform>
</TextBlock>
<Border Width="1" Height="20" Margin="14,0"
Background="{DynamicResource Ubisam.Brush.SubTabBar.Border}"/>
<!-- 장비명: IAppSetup.GetAppName()이 돌려주는 값 -->
<TextBlock Text="{Binding Title}"
FontSize="14.5"
Foreground="{DynamicResource Ubisam.Brush.Sidebar.Foreground}"
VerticalAlignment="Center"/>
</StackPanel>
<StackPanel Grid.Column="1" Orientation="Horizontal" VerticalAlignment="Center" Margin="0,0,20,0">
<TextBlock Text="{Binding CurrentDate}"
FontSize="11"
Foreground="{DynamicResource Ubisam.Brush.Muted}"
VerticalAlignment="Bottom"
Margin="0,0,12,2"/>
<TextBlock Text="{Binding CurrentTime}"
FontFamily="{StaticResource Ubisam.Font.Heading}"
FontWeight="SemiBold"
FontSize="25"
VerticalAlignment="Bottom"/>
</StackPanel>
<Button Grid.Column="2"
Content="{Binding Auth.UserDisplay}"
Style="{StaticResource Ubisam.Style.ChromeButton}"
VerticalAlignment="Center"
Margin="0,0,8,0"
Click="Account_Click"/>
<StackPanel Grid.Column="3" Orientation="Horizontal" VerticalAlignment="Center">
<Button Content="바탕화면"
Style="{StaticResource Ubisam.Style.ChromeButton}"
Click="ShowDesktop_Click"/>
<Button Content="종료"
Style="{StaticResource Ubisam.Style.ChromeButton}"
Margin="8,0,0,0"
Click="Exit_Click"/>
</StackPanel>
</Grid>
</Border>
<!-- 네비게이션: 기본은 하단(NavPosition.Bottom). 좌/우 전환은 코드비하인드가 처리.
높이는 고정하지 않는다 — 아이콘 유무와 ChromeScale에 따라 내용 높이로 정해진다. -->
<ListBox x:Name="NavList"
DockPanel.Dock="Bottom"
Style="{StaticResource Ubisam.Style.SidebarBar.Horizontal}"
ItemsSource="{Binding MainTabs}"
SelectedItem="{Binding SelectedMainTab, Mode=TwoWay}">
<ListBox.LayoutTransform>
<ScaleTransform ScaleX="{Binding UiSettings.ChromeScale}" ScaleY="{Binding UiSettings.ChromeScale}"/>
</ListBox.LayoutTransform>
</ListBox>
<!-- 컨텐츠 + 하단 서브탭. 컨텐츠는 여백 없이 영역 전체를 쓴다 —
내부 여백은 각 모듈 View가 직접 정한다. -->
<Grid>
<Grid.RowDefinitions>
<RowDefinition Height="*"/>
<RowDefinition Height="Auto"/>
</Grid.RowDefinitions>
<!-- 모듈이 없을 때만 사선 해치로 빈 슬롯을 표시하고, 모듈이 마운트되면 바탕색으로 돌아간다.
Padding은 모듈 컨텐츠만 안으로 밀어 넣는다 — 상단 헤더·하단 탭과 세로선을 맞춘 26px. -->
<Border Grid.Row="0" Padding="26,22">
<Border.Style>
<Style TargetType="Border">
<Setter Property="Background" Value="{DynamicResource Ubisam.Brush.ContentBackground}"/>
<Style.Triggers>
<DataTrigger Binding="{Binding SelectedMainTab.CurrentContent}" Value="{x:Null}">
<Setter Property="Background" Value="{DynamicResource Ubisam.Brush.SlotHatch}"/>
</DataTrigger>
</Style.Triggers>
</Style>
</Border.Style>
<ContentControl Content="{Binding SelectedMainTab.CurrentContent}"/>
</Border>
<ListBox Grid.Row="1"
Style="{StaticResource Ubisam.Style.SubTabBar}"
DisplayMemberPath="Title"
ItemsSource="{Binding SelectedMainTab.SubTabs}"
SelectedItem="{Binding SelectedMainTab.SelectedSubTab, Mode=TwoWay}"
Visibility="{Binding SelectedMainTab.HasSubTabs, Converter={StaticResource BoolToVis}}">
<ListBox.LayoutTransform>
<ScaleTransform ScaleX="{Binding UiSettings.ChromeScale}" ScaleY="{Binding UiSettings.ChromeScale}"/>
</ListBox.LayoutTransform>
</ListBox>
</Grid>
</DockPanel>
<!-- 토스트 오버레이 -->
<ItemsControl HorizontalAlignment="Right"
VerticalAlignment="Top"
Margin="0,80,20,0"
IsHitTestVisible="False">
<ItemsControl.ItemsSource>
<Binding Path="Messages" Source="{x:Static messaging:ToastService.Current}"/>
</ItemsControl.ItemsSource>
<ItemsControl.ItemTemplate>
<DataTemplate>
<Border Background="{Binding Type, Converter={StaticResource ToastTypeToBrush}}"
Padding="16,12"
Margin="0,0,0,8"
MaxWidth="360">
<TextBlock Text="{Binding Text}" Foreground="White" TextWrapping="Wrap" FontSize="13"/>
</Border>
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
</Grid>
</Window>

View File

@ -0,0 +1,54 @@
<ResourceDictionary xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml">
<!-- 다크: 검정이 아니라 차가운 회색 바탕 + 회사 하늘색.
판(#2A2E34)이 바탕(#23262B)보다 한 단계 밝아 층이 구분된다.
accent는 두 단계로 쓴다 — 선·글자·밑줄은 밝은 #4DA6F5, 글자를 얹는 채움면은
깊은 #1E7FD4 + 흰 글자(#F2F7FC). 밝은 단계에 검은 글자를 얹지 않는다. -->
<SolidColorBrush x:Key="Ubisam.Brush.ContentBackground" Color="#FF23262B"/>
<SolidColorBrush x:Key="Ubisam.Brush.ContentForeground" Color="#FFE8EAED"/>
<SolidColorBrush x:Key="Ubisam.Brush.Accent" Color="#FF4DA6F5"/>
<SolidColorBrush x:Key="Ubisam.Brush.Accent.Fill" Color="#FF1E7FD4"/>
<SolidColorBrush x:Key="Ubisam.Brush.Accent.OnFill" Color="#FFF2F7FC"/>
<!-- 오류 — 대화상자 아이콘과 실패 태그에만 쓴다. 장식용 색을 늘리지 않는다. -->
<SolidColorBrush x:Key="Ubisam.Brush.Danger" Color="#FFF07070"/>
<SolidColorBrush x:Key="Ubisam.Brush.Danger.Fill" Color="#FFB33A3A"/>
<SolidColorBrush x:Key="Ubisam.Brush.Sidebar.Background" Color="#FF2A2E34"/>
<SolidColorBrush x:Key="Ubisam.Brush.Sidebar.Foreground" Color="#FFC2C7CE"/>
<SolidColorBrush x:Key="Ubisam.Brush.Sidebar.HoverBackground" Color="#16FFFFFF"/>
<SolidColorBrush x:Key="Ubisam.Brush.Sidebar.SelectedBackground" Color="#1A4DA6F5"/>
<SolidColorBrush x:Key="Ubisam.Brush.Sidebar.SelectedForeground" Color="#FF4DA6F5"/>
<SolidColorBrush x:Key="Ubisam.Brush.SubTabBar.Background" Color="#FF2A2E34"/>
<SolidColorBrush x:Key="Ubisam.Brush.SubTabBar.Border" Color="#FF3A3F47"/>
<SolidColorBrush x:Key="Ubisam.Brush.SubTabBar.Foreground" Color="#FFC2C7CE"/>
<SolidColorBrush x:Key="Ubisam.Brush.SubTabBar.HoverBackground" Color="#16FFFFFF"/>
<SolidColorBrush x:Key="Ubisam.Brush.SubTabBar.SelectedBackground" Color="#FF1E7FD4"/>
<SolidColorBrush x:Key="Ubisam.Brush.SubTabBar.SelectedForeground" Color="#FFF2F7FC"/>
<!-- 입력 컨트롤 -->
<SolidColorBrush x:Key="Ubisam.Brush.Input.Background" Color="#FF2F343A"/>
<SolidColorBrush x:Key="Ubisam.Brush.Input.Border" Color="#FF454B54"/>
<SolidColorBrush x:Key="Ubisam.Brush.Input.BorderHover" Color="#FF5A616B"/>
<SolidColorBrush x:Key="Ubisam.Brush.Input.DisabledBackground" Color="#FF292D32"/>
<SolidColorBrush x:Key="Ubisam.Brush.Input.DisabledBorder" Color="#FF383D44"/>
<Color x:Key="Ubisam.Color.AccentGlow">#FF4DA6F5</Color>
<!-- 표 -->
<SolidColorBrush x:Key="Ubisam.Brush.Table.HeaderBackground" Color="#FF2F343A"/>
<SolidColorBrush x:Key="Ubisam.Brush.Table.RowBorder" Color="#FF383D44"/>
<SolidColorBrush x:Key="Ubisam.Brush.Table.GridLine" Color="#FF383D44"/>
<SolidColorBrush x:Key="Ubisam.Brush.Table.AltRowBackground" Color="#FF333840"/>
<!-- 상태 태그 -->
<SolidColorBrush x:Key="Ubisam.Brush.Tag.AccentBackground" Color="#FF123A5C"/>
<SolidColorBrush x:Key="Ubisam.Brush.Tag.AccentForeground" Color="#FFA8D4FA"/>
<SolidColorBrush x:Key="Ubisam.Brush.Tag.NeutralBackground" Color="#FF383D44"/>
<SolidColorBrush x:Key="Ubisam.Brush.Tag.NeutralForeground" Color="#FFC2C7CE"/>
<SolidColorBrush x:Key="Ubisam.Brush.Slot.Hatch" Color="#1A4DA6F5"/>
<SolidColorBrush x:Key="Ubisam.Brush.Muted" Color="#FF8A9099"/>
</ResourceDictionary>

View File

@ -0,0 +1,54 @@
<ResourceDictionary xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml">
<!-- 라이트: 기술 도면 톤의 밝은 바탕 + 회사 하늘색 accent -->
<SolidColorBrush x:Key="Ubisam.Brush.ContentBackground" Color="#FFF2F2F3"/>
<SolidColorBrush x:Key="Ubisam.Brush.ContentForeground" Color="#FF1D1F20"/>
<SolidColorBrush x:Key="Ubisam.Brush.Accent" Color="#FF2196F3"/>
<!-- 글자를 얹는 채움면용 깊은 단계 + 그 위 글자색 (다크와 키를 공유) -->
<SolidColorBrush x:Key="Ubisam.Brush.Accent.Fill" Color="#FF1D82E8"/>
<SolidColorBrush x:Key="Ubisam.Brush.Accent.OnFill" Color="#FFFFFFFF"/>
<!-- 오류 — 대화상자 아이콘과 실패 태그에만 쓴다. 장식용 색을 늘리지 않는다. -->
<SolidColorBrush x:Key="Ubisam.Brush.Danger" Color="#FFC93B3B"/>
<SolidColorBrush x:Key="Ubisam.Brush.Danger.Fill" Color="#FFB32F2F"/>
<!-- 상단 브랜딩 바 = 흰 판, 하단 탭 바와 같은 계열 -->
<SolidColorBrush x:Key="Ubisam.Brush.Sidebar.Background" Color="#FFFFFFFF"/>
<SolidColorBrush x:Key="Ubisam.Brush.Sidebar.Foreground" Color="#FF5D5D60"/>
<SolidColorBrush x:Key="Ubisam.Brush.Sidebar.HoverBackground" Color="#14000000"/>
<SolidColorBrush x:Key="Ubisam.Brush.Sidebar.SelectedBackground" Color="#122196F3"/>
<SolidColorBrush x:Key="Ubisam.Brush.Sidebar.SelectedForeground" Color="#FF2196F3"/>
<SolidColorBrush x:Key="Ubisam.Brush.SubTabBar.Background" Color="#FFFFFFFF"/>
<SolidColorBrush x:Key="Ubisam.Brush.SubTabBar.Border" Color="#FFD6D6D9"/>
<SolidColorBrush x:Key="Ubisam.Brush.SubTabBar.Foreground" Color="#FF5D5D60"/>
<SolidColorBrush x:Key="Ubisam.Brush.SubTabBar.HoverBackground" Color="#14000000"/>
<SolidColorBrush x:Key="Ubisam.Brush.SubTabBar.SelectedBackground" Color="#FF2196F3"/>
<SolidColorBrush x:Key="Ubisam.Brush.SubTabBar.SelectedForeground" Color="#FFFFFFFF"/>
<!-- 입력 컨트롤 -->
<SolidColorBrush x:Key="Ubisam.Brush.Input.Background" Color="#FFFFFFFF"/>
<SolidColorBrush x:Key="Ubisam.Brush.Input.Border" Color="#FFC9C9CC"/>
<SolidColorBrush x:Key="Ubisam.Brush.Input.BorderHover" Color="#FFA8A8AC"/>
<SolidColorBrush x:Key="Ubisam.Brush.Input.DisabledBackground" Color="#FFEDEDEF"/>
<SolidColorBrush x:Key="Ubisam.Brush.Input.DisabledBorder" Color="#FFDEDEE1"/>
<Color x:Key="Ubisam.Color.AccentGlow">#FF2196F3</Color>
<!-- 표 -->
<SolidColorBrush x:Key="Ubisam.Brush.Table.HeaderBackground" Color="#FFEDEDEF"/>
<SolidColorBrush x:Key="Ubisam.Brush.Table.RowBorder" Color="#FFEDEDEF"/>
<SolidColorBrush x:Key="Ubisam.Brush.Table.GridLine" Color="#FFDEDEE1"/>
<SolidColorBrush x:Key="Ubisam.Brush.Table.AltRowBackground" Color="#FFF8F8F9"/>
<!-- 상태 태그 -->
<SolidColorBrush x:Key="Ubisam.Brush.Tag.AccentBackground" Color="#FFDCEBFA"/>
<SolidColorBrush x:Key="Ubisam.Brush.Tag.AccentForeground" Color="#FF0B4C7E"/>
<SolidColorBrush x:Key="Ubisam.Brush.Tag.NeutralBackground" Color="#FFEDEDEF"/>
<SolidColorBrush x:Key="Ubisam.Brush.Tag.NeutralForeground" Color="#FF3F3F42"/>
<!-- 빈 뷰 슬롯의 사선 해치 색 -->
<SolidColorBrush x:Key="Ubisam.Brush.Slot.Hatch" Color="#172196F3"/>
<SolidColorBrush x:Key="Ubisam.Brush.Muted" Color="#FF98989B"/>
</ResourceDictionary>

View File

@ -0,0 +1,803 @@
<!-- 입력 컨트롤 · 표 스타일. 전부 암시적 스타일(x:Key 없음)이라
각 View를 고치지 않아도 셸 안의 모든 TextBox/ComboBox/CheckBox/Button/ListBox/ListView에 적용된다.
특정 위치만 예외로 두고 싶으면 그 컨트롤에 Style="{x:Null}"을 주면 된다. -->
<ResourceDictionary xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml">
<!-- ══ TextBox ══ -->
<Style TargetType="TextBox">
<Setter Property="MinHeight" Value="34"/>
<Setter Property="Padding" Value="10,0"/>
<Setter Property="FontFamily" Value="{DynamicResource Ubisam.Font.Body}"/>
<Setter Property="FontSize" Value="13.5"/>
<Setter Property="Foreground" Value="{DynamicResource Ubisam.Brush.ContentForeground}"/>
<Setter Property="CaretBrush" Value="{DynamicResource Ubisam.Brush.Accent}"/>
<Setter Property="SelectionBrush" Value="{DynamicResource Ubisam.Brush.Accent}"/>
<Setter Property="VerticalContentAlignment" Value="Center"/>
<Setter Property="Template">
<Setter.Value>
<ControlTemplate TargetType="TextBox">
<Border x:Name="Bd"
Background="{DynamicResource Ubisam.Brush.Input.Background}"
BorderBrush="{DynamicResource Ubisam.Brush.Input.Border}"
BorderThickness="1"
SnapsToDevicePixels="True">
<ScrollViewer x:Name="PART_ContentHost"
Margin="{TemplateBinding Padding}"
VerticalAlignment="Center"
Focusable="False"
HorizontalScrollBarVisibility="Hidden"
VerticalScrollBarVisibility="Hidden"/>
</Border>
<ControlTemplate.Triggers>
<Trigger Property="IsMouseOver" Value="True">
<Setter TargetName="Bd" Property="BorderBrush" Value="{DynamicResource Ubisam.Brush.Input.BorderHover}"/>
</Trigger>
<Trigger Property="IsKeyboardFocusWithin" Value="True">
<Setter TargetName="Bd" Property="BorderBrush" Value="{DynamicResource Ubisam.Brush.Accent}"/>
<Setter TargetName="Bd" Property="Effect">
<Setter.Value>
<DropShadowEffect Color="{DynamicResource Ubisam.Color.AccentGlow}"
BlurRadius="6" ShadowDepth="0" Opacity="0.55"/>
</Setter.Value>
</Setter>
</Trigger>
<MultiTrigger>
<MultiTrigger.Conditions>
<Condition Property="IsEnabled" Value="False"/>
</MultiTrigger.Conditions>
<Setter TargetName="Bd" Property="Background" Value="{DynamicResource Ubisam.Brush.Input.DisabledBackground}"/>
<Setter TargetName="Bd" Property="BorderBrush" Value="{DynamicResource Ubisam.Brush.Input.DisabledBorder}"/>
<Setter Property="Foreground" Value="{DynamicResource Ubisam.Brush.Muted}"/>
</MultiTrigger>
<Trigger Property="IsReadOnly" Value="True">
<Setter TargetName="Bd" Property="Background" Value="{DynamicResource Ubisam.Brush.Input.DisabledBackground}"/>
</Trigger>
</ControlTemplate.Triggers>
</ControlTemplate>
</Setter.Value>
</Setter>
</Style>
<!-- ══ ComboBox ══ -->
<Style x:Key="Ubisam.Style.ComboToggle" TargetType="ToggleButton">
<Setter Property="Focusable" Value="False"/>
<Setter Property="ClickMode" Value="Press"/>
<Setter Property="Template">
<Setter.Value>
<ControlTemplate TargetType="ToggleButton">
<Border x:Name="Bd"
Background="{DynamicResource Ubisam.Brush.Input.Background}"
BorderBrush="{DynamicResource Ubisam.Brush.Input.Border}"
BorderThickness="1"
SnapsToDevicePixels="True">
<Path x:Name="Arrow"
Data="M 0,0 L 5,5 L 10,0"
Stroke="{DynamicResource Ubisam.Brush.Sidebar.Foreground}"
StrokeThickness="1.5"
StrokeStartLineCap="Round"
StrokeEndLineCap="Round"
StrokeLineJoin="Round"
HorizontalAlignment="Right"
VerticalAlignment="Center"
Margin="0,0,11,0"/>
</Border>
<ControlTemplate.Triggers>
<Trigger Property="IsMouseOver" Value="True">
<Setter TargetName="Bd" Property="BorderBrush" Value="{DynamicResource Ubisam.Brush.Input.BorderHover}"/>
</Trigger>
<Trigger Property="IsChecked" Value="True">
<Setter TargetName="Bd" Property="BorderBrush" Value="{DynamicResource Ubisam.Brush.Accent}"/>
<Setter TargetName="Arrow" Property="Stroke" Value="{DynamicResource Ubisam.Brush.Accent}"/>
<Setter TargetName="Arrow" Property="RenderTransformOrigin" Value="0.5,0.5"/>
<Setter TargetName="Arrow" Property="RenderTransform">
<Setter.Value>
<ScaleTransform ScaleY="-1"/>
</Setter.Value>
</Setter>
</Trigger>
<Trigger Property="IsEnabled" Value="False">
<Setter TargetName="Bd" Property="Background" Value="{DynamicResource Ubisam.Brush.Input.DisabledBackground}"/>
<Setter TargetName="Bd" Property="BorderBrush" Value="{DynamicResource Ubisam.Brush.Input.DisabledBorder}"/>
<Setter TargetName="Arrow" Property="Stroke" Value="{DynamicResource Ubisam.Brush.Muted}"/>
</Trigger>
</ControlTemplate.Triggers>
</ControlTemplate>
</Setter.Value>
</Setter>
</Style>
<Style TargetType="ComboBoxItem">
<Setter Property="Padding" Value="11,8"/>
<Setter Property="FontFamily" Value="{DynamicResource Ubisam.Font.Body}"/>
<Setter Property="FontSize" Value="13.5"/>
<Setter Property="Foreground" Value="{DynamicResource Ubisam.Brush.ContentForeground}"/>
<Setter Property="Cursor" Value="Hand"/>
<Setter Property="Template">
<Setter.Value>
<ControlTemplate TargetType="ComboBoxItem">
<Border x:Name="Bd" Background="Transparent" Padding="{TemplateBinding Padding}">
<ContentPresenter VerticalAlignment="Center"/>
</Border>
<ControlTemplate.Triggers>
<Trigger Property="IsMouseOver" Value="True">
<Setter TargetName="Bd" Property="Background" Value="{DynamicResource Ubisam.Brush.Sidebar.SelectedBackground}"/>
<Setter Property="Foreground" Value="{DynamicResource Ubisam.Brush.Sidebar.SelectedForeground}"/>
</Trigger>
<Trigger Property="IsSelected" Value="True">
<Setter TargetName="Bd" Property="Background" Value="{DynamicResource Ubisam.Brush.Accent.Fill}"/>
<Setter Property="Foreground" Value="{DynamicResource Ubisam.Brush.Accent.OnFill}"/>
<Setter Property="FontWeight" Value="SemiBold"/>
</Trigger>
</ControlTemplate.Triggers>
</ControlTemplate>
</Setter.Value>
</Setter>
</Style>
<Style TargetType="ComboBox">
<Setter Property="MinHeight" Value="34"/>
<Setter Property="Padding" Value="10,0,30,0"/>
<Setter Property="FontFamily" Value="{DynamicResource Ubisam.Font.Body}"/>
<Setter Property="FontSize" Value="13.5"/>
<Setter Property="Foreground" Value="{DynamicResource Ubisam.Brush.ContentForeground}"/>
<Setter Property="Cursor" Value="Hand"/>
<Setter Property="Template">
<Setter.Value>
<ControlTemplate TargetType="ComboBox">
<Grid>
<ToggleButton x:Name="ToggleButton"
Style="{StaticResource Ubisam.Style.ComboToggle}"
IsChecked="{Binding IsDropDownOpen, Mode=TwoWay, RelativeSource={RelativeSource TemplatedParent}}"/>
<ContentPresenter x:Name="ContentSite"
Content="{TemplateBinding SelectionBoxItem}"
ContentTemplate="{TemplateBinding SelectionBoxItemTemplate}"
ContentStringFormat="{TemplateBinding SelectionBoxItemStringFormat}"
Margin="{TemplateBinding Padding}"
VerticalAlignment="Center"
HorizontalAlignment="Left"
IsHitTestVisible="False"/>
<TextBox x:Name="PART_EditableTextBox"
Style="{x:Null}"
Visibility="Collapsed"
Margin="{TemplateBinding Padding}"
Background="Transparent"
BorderThickness="0"/>
<Popup x:Name="Popup"
Placement="Bottom"
IsOpen="{TemplateBinding IsDropDownOpen}"
AllowsTransparency="True"
Focusable="False"
PopupAnimation="Fade">
<Border MinWidth="{TemplateBinding ActualWidth}"
MaxHeight="{TemplateBinding MaxDropDownHeight}"
Background="{DynamicResource Ubisam.Brush.Input.Background}"
BorderBrush="{DynamicResource Ubisam.Brush.Input.Border}"
BorderThickness="1,0,1,1"
Effect="{DynamicResource Ubisam.Effect.Popup}">
<ScrollViewer>
<ItemsPresenter/>
</ScrollViewer>
</Border>
</Popup>
</Grid>
<ControlTemplate.Triggers>
<Trigger Property="IsEditable" Value="True">
<Setter TargetName="PART_EditableTextBox" Property="Visibility" Value="Visible"/>
<Setter TargetName="ContentSite" Property="Visibility" Value="Collapsed"/>
</Trigger>
<Trigger Property="IsEnabled" Value="False">
<Setter Property="Foreground" Value="{DynamicResource Ubisam.Brush.Muted}"/>
</Trigger>
</ControlTemplate.Triggers>
</ControlTemplate>
</Setter.Value>
</Setter>
</Style>
<!-- ══ CheckBox ══ -->
<Style TargetType="CheckBox">
<Setter Property="FontFamily" Value="{DynamicResource Ubisam.Font.Body}"/>
<Setter Property="FontSize" Value="13.5"/>
<Setter Property="Foreground" Value="{DynamicResource Ubisam.Brush.ContentForeground}"/>
<Setter Property="Cursor" Value="Hand"/>
<Setter Property="Template">
<Setter.Value>
<ControlTemplate TargetType="CheckBox">
<StackPanel Orientation="Horizontal">
<Border x:Name="Box"
Width="17" Height="17"
Background="{DynamicResource Ubisam.Brush.Input.Background}"
BorderBrush="{DynamicResource Ubisam.Brush.Input.Border}"
BorderThickness="1"
SnapsToDevicePixels="True"
VerticalAlignment="Center">
<Path x:Name="Check"
Data="M 1,6 L 5,10 L 12,2"
Stroke="{DynamicResource Ubisam.Brush.Accent.OnFill}"
StrokeThickness="2"
StrokeStartLineCap="Round"
StrokeEndLineCap="Round"
StrokeLineJoin="Round"
Visibility="Collapsed"
HorizontalAlignment="Center"
VerticalAlignment="Center"/>
</Border>
<ContentPresenter Margin="9,0,0,0" VerticalAlignment="Center"/>
</StackPanel>
<ControlTemplate.Triggers>
<Trigger Property="IsMouseOver" Value="True">
<Setter TargetName="Box" Property="BorderBrush" Value="{DynamicResource Ubisam.Brush.Input.BorderHover}"/>
</Trigger>
<Trigger Property="IsChecked" Value="True">
<Setter TargetName="Box" Property="Background" Value="{DynamicResource Ubisam.Brush.Accent.Fill}"/>
<Setter TargetName="Box" Property="BorderBrush" Value="{DynamicResource Ubisam.Brush.Accent.Fill}"/>
<Setter TargetName="Check" Property="Visibility" Value="Visible"/>
</Trigger>
<Trigger Property="IsEnabled" Value="False">
<Setter TargetName="Box" Property="Background" Value="{DynamicResource Ubisam.Brush.Input.DisabledBackground}"/>
<Setter TargetName="Box" Property="BorderBrush" Value="{DynamicResource Ubisam.Brush.Input.DisabledBorder}"/>
<Setter Property="Foreground" Value="{DynamicResource Ubisam.Brush.Muted}"/>
</Trigger>
</ControlTemplate.Triggers>
</ControlTemplate>
</Setter.Value>
</Setter>
</Style>
<!-- ══ Button ══
암시적 = 보조(외곽선). 주 동작에는 Style="{StaticResource Ubisam.Style.Button.Primary}" -->
<Style TargetType="Button">
<Setter Property="MinHeight" Value="34"/>
<Setter Property="Padding" Value="20,8"/>
<Setter Property="FontFamily" Value="{DynamicResource Ubisam.Font.Body}"/>
<Setter Property="FontSize" Value="13.5"/>
<Setter Property="FontWeight" Value="Medium"/>
<Setter Property="Foreground" Value="{DynamicResource Ubisam.Brush.Sidebar.Foreground}"/>
<Setter Property="Cursor" Value="Hand"/>
<Setter Property="Template">
<Setter.Value>
<ControlTemplate TargetType="Button">
<Border x:Name="Bd"
Background="{DynamicResource Ubisam.Brush.Input.Background}"
BorderBrush="{DynamicResource Ubisam.Brush.Input.Border}"
BorderThickness="1"
Padding="{TemplateBinding Padding}"
SnapsToDevicePixels="True">
<ContentPresenter HorizontalAlignment="Center" VerticalAlignment="Center"/>
</Border>
<ControlTemplate.Triggers>
<Trigger Property="IsMouseOver" Value="True">
<Setter TargetName="Bd" Property="Background" Value="{DynamicResource Ubisam.Brush.Sidebar.SelectedBackground}"/>
<Setter TargetName="Bd" Property="BorderBrush" Value="{DynamicResource Ubisam.Brush.Accent}"/>
<Setter Property="Foreground" Value="{DynamicResource Ubisam.Brush.Sidebar.SelectedForeground}"/>
</Trigger>
<Trigger Property="IsPressed" Value="True">
<Setter TargetName="Bd" Property="Background" Value="{DynamicResource Ubisam.Brush.Sidebar.HoverBackground}"/>
</Trigger>
<Trigger Property="IsEnabled" Value="False">
<Setter TargetName="Bd" Property="Opacity" Value="0.45"/>
</Trigger>
</ControlTemplate.Triggers>
</ControlTemplate>
</Setter.Value>
</Setter>
</Style>
<Style x:Key="Ubisam.Style.Button.Primary" TargetType="Button" BasedOn="{StaticResource {x:Type Button}}">
<Setter Property="Foreground" Value="{DynamicResource Ubisam.Brush.Accent.OnFill}"/>
<Setter Property="FontWeight" Value="SemiBold"/>
<Setter Property="Template">
<Setter.Value>
<ControlTemplate TargetType="Button">
<Border x:Name="Bd"
Background="{DynamicResource Ubisam.Brush.Accent.Fill}"
BorderThickness="0"
Padding="{TemplateBinding Padding}"
SnapsToDevicePixels="True">
<ContentPresenter HorizontalAlignment="Center" VerticalAlignment="Center"/>
</Border>
<ControlTemplate.Triggers>
<Trigger Property="IsMouseOver" Value="True">
<Setter TargetName="Bd" Property="Opacity" Value="0.88"/>
</Trigger>
<Trigger Property="IsPressed" Value="True">
<Setter TargetName="Bd" Property="Opacity" Value="0.75"/>
</Trigger>
<Trigger Property="IsEnabled" Value="False">
<Setter TargetName="Bd" Property="Opacity" Value="0.45"/>
</Trigger>
</ControlTemplate.Triggers>
</ControlTemplate>
</Setter.Value>
</Setter>
</Style>
<Style x:Key="Ubisam.Style.Button.Ghost" TargetType="Button" BasedOn="{StaticResource {x:Type Button}}">
<Setter Property="Template">
<Setter.Value>
<ControlTemplate TargetType="Button">
<Border x:Name="Bd" Background="Transparent" Padding="{TemplateBinding Padding}">
<ContentPresenter HorizontalAlignment="Center" VerticalAlignment="Center"/>
</Border>
<ControlTemplate.Triggers>
<Trigger Property="IsMouseOver" Value="True">
<Setter TargetName="Bd" Property="Background" Value="{DynamicResource Ubisam.Brush.Sidebar.HoverBackground}"/>
<Setter Property="Foreground" Value="{DynamicResource Ubisam.Brush.Sidebar.SelectedForeground}"/>
</Trigger>
</ControlTemplate.Triggers>
</ControlTemplate>
</Setter.Value>
</Setter>
</Style>
<!-- ══ 일반 ListBox (설정 키 목록 등) ══ -->
<Style TargetType="ListBoxItem">
<Setter Property="Padding" Value="11,8"/>
<Setter Property="FontFamily" Value="{DynamicResource Ubisam.Font.Body}"/>
<Setter Property="FontSize" Value="13.5"/>
<Setter Property="Foreground" Value="{DynamicResource Ubisam.Brush.ContentForeground}"/>
<Setter Property="Cursor" Value="Hand"/>
<Setter Property="HorizontalContentAlignment" Value="Stretch"/>
<Setter Property="Template">
<Setter.Value>
<ControlTemplate TargetType="ListBoxItem">
<Border x:Name="Bd"
Background="Transparent"
BorderThickness="2,0,0,0"
BorderBrush="Transparent"
Padding="{TemplateBinding Padding}">
<ContentPresenter VerticalAlignment="Center"/>
</Border>
<ControlTemplate.Triggers>
<Trigger Property="IsMouseOver" Value="True">
<Setter TargetName="Bd" Property="Background" Value="{DynamicResource Ubisam.Brush.Sidebar.HoverBackground}"/>
</Trigger>
<Trigger Property="IsSelected" Value="True">
<Setter TargetName="Bd" Property="Background" Value="{DynamicResource Ubisam.Brush.Sidebar.SelectedBackground}"/>
<Setter TargetName="Bd" Property="BorderBrush" Value="{DynamicResource Ubisam.Brush.Accent}"/>
<Setter Property="Foreground" Value="{DynamicResource Ubisam.Brush.Sidebar.SelectedForeground}"/>
<Setter Property="FontWeight" Value="SemiBold"/>
</Trigger>
</ControlTemplate.Triggers>
</ControlTemplate>
</Setter.Value>
</Setter>
</Style>
<Style TargetType="ListBox">
<Setter Property="Background" Value="{DynamicResource Ubisam.Brush.Input.Background}"/>
<Setter Property="BorderBrush" Value="{DynamicResource Ubisam.Brush.Input.Border}"/>
<Setter Property="BorderThickness" Value="1"/>
<Setter Property="Padding" Value="0,4"/>
</Style>
<!-- ══ ListView + GridView (로그 표) ══ -->
<Style TargetType="GridViewColumnHeader">
<Setter Property="Padding" Value="11,9"/>
<Setter Property="FontFamily" Value="{DynamicResource Ubisam.Font.Body}"/>
<Setter Property="FontSize" Value="11"/>
<Setter Property="FontWeight" Value="SemiBold"/>
<Setter Property="Foreground" Value="{DynamicResource Ubisam.Brush.Sidebar.Foreground}"/>
<Setter Property="HorizontalContentAlignment" Value="Left"/>
<Setter Property="Template">
<Setter.Value>
<ControlTemplate TargetType="GridViewColumnHeader">
<Border x:Name="Bd"
Background="{DynamicResource Ubisam.Brush.Table.HeaderBackground}"
BorderBrush="{DynamicResource Ubisam.Brush.Input.Border}"
BorderThickness="0,0,1,1"
Padding="{TemplateBinding Padding}">
<ContentPresenter VerticalAlignment="Center"/>
</Border>
<ControlTemplate.Triggers>
<Trigger Property="IsMouseOver" Value="True">
<Setter TargetName="Bd" Property="Background" Value="{DynamicResource Ubisam.Brush.Sidebar.HoverBackground}"/>
</Trigger>
</ControlTemplate.Triggers>
</ControlTemplate>
</Setter.Value>
</Setter>
</Style>
<Style x:Key="Ubisam.Style.TableRow" TargetType="ListViewItem">
<Setter Property="Padding" Value="0"/>
<Setter Property="FontFamily" Value="{DynamicResource Ubisam.Font.Body}"/>
<Setter Property="FontSize" Value="12.5"/>
<Setter Property="Foreground" Value="{DynamicResource Ubisam.Brush.ContentForeground}"/>
<Setter Property="HorizontalContentAlignment" Value="Stretch"/>
<Setter Property="Template">
<Setter.Value>
<ControlTemplate TargetType="ListViewItem">
<Border x:Name="Bd"
Background="Transparent"
BorderBrush="{DynamicResource Ubisam.Brush.Table.RowBorder}"
BorderThickness="0,0,0,1"
Padding="0,4">
<GridViewRowPresenter Content="{TemplateBinding Content}"
Columns="{TemplateBinding GridView.ColumnCollection}"
VerticalAlignment="Center"/>
</Border>
<ControlTemplate.Triggers>
<Trigger Property="IsMouseOver" Value="True">
<Setter TargetName="Bd" Property="Background" Value="{DynamicResource Ubisam.Brush.Sidebar.HoverBackground}"/>
</Trigger>
<Trigger Property="IsSelected" Value="True">
<Setter TargetName="Bd" Property="Background" Value="{DynamicResource Ubisam.Brush.Sidebar.SelectedBackground}"/>
<Setter Property="Foreground" Value="{DynamicResource Ubisam.Brush.Sidebar.SelectedForeground}"/>
</Trigger>
</ControlTemplate.Triggers>
</ControlTemplate>
</Setter.Value>
</Setter>
</Style>
<Style TargetType="ListView">
<Setter Property="Background" Value="{DynamicResource Ubisam.Brush.Input.Background}"/>
<Setter Property="BorderBrush" Value="{DynamicResource Ubisam.Brush.Input.Border}"/>
<Setter Property="BorderThickness" Value="1"/>
<Setter Property="ItemContainerStyle" Value="{StaticResource Ubisam.Style.TableRow}"/>
<Setter Property="ScrollViewer.HorizontalScrollBarVisibility" Value="Auto"/>
</Style>
<!-- ══ DataGrid ══
ListView+GridView가 읽기 전용 표라면 DataGrid는 편집 가능한 격자다.
셀 구분선은 세로만 그리고(가로는 행 경계가 담당), 선택은 행 머리의 accent 바 + 행 틴트로 알린다. -->
<!-- 정렬 화살표 -->
<Geometry x:Key="Ubisam.Geometry.SortAsc">M1,6 L5.5,1 L10,6</Geometry>
<Geometry x:Key="Ubisam.Geometry.SortDesc">M1,1 L5.5,6 L10,1</Geometry>
<Style TargetType="DataGridColumnHeader">
<Setter Property="Padding" Value="11,10"/>
<Setter Property="FontFamily" Value="{DynamicResource Ubisam.Font.Body}"/>
<Setter Property="FontSize" Value="11"/>
<Setter Property="FontWeight" Value="SemiBold"/>
<Setter Property="Foreground" Value="{DynamicResource Ubisam.Brush.Sidebar.Foreground}"/>
<Setter Property="HorizontalContentAlignment" Value="Left"/>
<Setter Property="VerticalContentAlignment" Value="Center"/>
<Setter Property="Template">
<Setter.Value>
<ControlTemplate TargetType="DataGridColumnHeader">
<Grid>
<Border x:Name="Bd"
Background="{DynamicResource Ubisam.Brush.Table.HeaderBackground}"
BorderBrush="{DynamicResource Ubisam.Brush.Table.GridLine}"
BorderThickness="0,0,1,0"
Padding="{TemplateBinding Padding}">
<Grid>
<Grid.ColumnDefinitions>
<ColumnDefinition Width="*"/>
<ColumnDefinition Width="Auto"/>
</Grid.ColumnDefinitions>
<ContentPresenter Grid.Column="0"
VerticalAlignment="Center"
HorizontalAlignment="{TemplateBinding HorizontalContentAlignment}"/>
<Path x:Name="SortArrow"
Grid.Column="1"
Margin="7,0,0,0"
Data="{StaticResource Ubisam.Geometry.SortDesc}"
Stroke="{DynamicResource Ubisam.Brush.Accent}"
StrokeThickness="2"
StrokeStartLineCap="Round"
StrokeEndLineCap="Round"
StrokeLineJoin="Round"
VerticalAlignment="Center"
Visibility="Collapsed"/>
</Grid>
</Border>
<Thumb x:Name="PART_RightHeaderGripper"
HorizontalAlignment="Right"
Width="6"
Cursor="SizeWE"
Opacity="0">
<Thumb.Template>
<ControlTemplate TargetType="Thumb">
<Border Background="Transparent"/>
</ControlTemplate>
</Thumb.Template>
</Thumb>
</Grid>
<ControlTemplate.Triggers>
<Trigger Property="IsMouseOver" Value="True">
<Setter TargetName="Bd" Property="Background" Value="{DynamicResource Ubisam.Brush.Sidebar.HoverBackground}"/>
</Trigger>
<Trigger Property="SortDirection" Value="Ascending">
<Setter TargetName="SortArrow" Property="Visibility" Value="Visible"/>
<Setter TargetName="SortArrow" Property="Data" Value="{StaticResource Ubisam.Geometry.SortAsc}"/>
<Setter Property="Foreground" Value="{DynamicResource Ubisam.Brush.Sidebar.SelectedForeground}"/>
</Trigger>
<Trigger Property="SortDirection" Value="Descending">
<Setter TargetName="SortArrow" Property="Visibility" Value="Visible"/>
<Setter TargetName="SortArrow" Property="Data" Value="{StaticResource Ubisam.Geometry.SortDesc}"/>
<Setter Property="Foreground" Value="{DynamicResource Ubisam.Brush.Sidebar.SelectedForeground}"/>
</Trigger>
</ControlTemplate.Triggers>
</ControlTemplate>
</Setter.Value>
</Setter>
</Style>
<!-- 행 머리: 폭 26, 선택 시 accent 바가 채워진다 -->
<Style TargetType="DataGridRowHeader">
<Setter Property="Width" Value="26"/>
<Setter Property="Template">
<Setter.Value>
<ControlTemplate TargetType="DataGridRowHeader">
<Border x:Name="Bd"
Background="Transparent"
BorderBrush="{DynamicResource Ubisam.Brush.Table.GridLine}"
BorderThickness="0,0,1,0"/>
<ControlTemplate.Triggers>
<DataTrigger Binding="{Binding IsSelected, RelativeSource={RelativeSource AncestorType=DataGridRow}}" Value="True">
<Setter TargetName="Bd" Property="Background" Value="{DynamicResource Ubisam.Brush.Accent}"/>
</DataTrigger>
</ControlTemplate.Triggers>
</ControlTemplate>
</Setter.Value>
</Setter>
</Style>
<Style TargetType="DataGridCell">
<Setter Property="Padding" Value="11,9"/>
<Setter Property="BorderThickness" Value="0"/>
<Setter Property="Foreground" Value="{DynamicResource Ubisam.Brush.ContentForeground}"/>
<Setter Property="Template">
<Setter.Value>
<ControlTemplate TargetType="DataGridCell">
<Border x:Name="Bd"
Background="Transparent"
BorderBrush="{DynamicResource Ubisam.Brush.Table.RowBorder}"
BorderThickness="0,0,1,0"
Padding="{TemplateBinding Padding}">
<ContentPresenter VerticalAlignment="Center"/>
</Border>
<ControlTemplate.Triggers>
<!-- 편집 중인 셀만 accent 테두리 + 내부 여백을 줄여 입력칸을 보여준다 -->
<Trigger Property="IsEditing" Value="True">
<Setter TargetName="Bd" Property="Background" Value="{DynamicResource Ubisam.Brush.Input.Background}"/>
<Setter TargetName="Bd" Property="BorderBrush" Value="{DynamicResource Ubisam.Brush.Accent}"/>
<Setter TargetName="Bd" Property="BorderThickness" Value="1"/>
<Setter TargetName="Bd" Property="Padding" Value="6,4"/>
</Trigger>
</ControlTemplate.Triggers>
</ControlTemplate>
</Setter.Value>
</Setter>
</Style>
<Style TargetType="DataGridRow">
<Setter Property="Background" Value="Transparent"/>
<Setter Property="FontFamily" Value="{DynamicResource Ubisam.Font.Body}"/>
<Setter Property="FontSize" Value="12.5"/>
<Setter Property="Foreground" Value="{DynamicResource Ubisam.Brush.ContentForeground}"/>
<Style.Triggers>
<Trigger Property="AlternationIndex" Value="1">
<Setter Property="Background" Value="{DynamicResource Ubisam.Brush.Table.AltRowBackground}"/>
</Trigger>
<Trigger Property="IsMouseOver" Value="True">
<Setter Property="Background" Value="{DynamicResource Ubisam.Brush.Sidebar.HoverBackground}"/>
</Trigger>
<Trigger Property="IsSelected" Value="True">
<Setter Property="Background" Value="{DynamicResource Ubisam.Brush.Sidebar.SelectedBackground}"/>
<Setter Property="Foreground" Value="{DynamicResource Ubisam.Brush.Sidebar.SelectedForeground}"/>
</Trigger>
</Style.Triggers>
</Style>
<Style TargetType="DataGrid">
<Setter Property="Background" Value="{DynamicResource Ubisam.Brush.Input.Background}"/>
<Setter Property="BorderBrush" Value="{DynamicResource Ubisam.Brush.Input.Border}"/>
<Setter Property="BorderThickness" Value="1"/>
<Setter Property="RowBackground" Value="Transparent"/>
<Setter Property="AlternatingRowBackground" Value="{DynamicResource Ubisam.Brush.Table.AltRowBackground}"/>
<Setter Property="RowHeight" Value="34"/>
<Setter Property="ColumnHeaderHeight" Value="34"/>
<Setter Property="GridLinesVisibility" Value="Horizontal"/>
<Setter Property="HorizontalGridLinesBrush" Value="{DynamicResource Ubisam.Brush.Table.RowBorder}"/>
<Setter Property="VerticalGridLinesBrush" Value="{DynamicResource Ubisam.Brush.Table.RowBorder}"/>
<Setter Property="HeadersVisibility" Value="All"/>
<Setter Property="SelectionUnit" Value="FullRow"/>
<Setter Property="GridLinesVisibility" Value="All"/>
<Setter Property="AutoGenerateColumns" Value="False"/>
<Setter Property="CanUserAddRows" Value="False"/>
<Setter Property="CanUserDeleteRows" Value="False"/>
<Setter Property="CanUserResizeRows" Value="False"/>
<Setter Property="EnableRowVirtualization" Value="True"/>
</Style>
<!-- 수치 열에 붙일 셀 스타일: 우측 정렬 + 자릿수 고정 -->
<Style x:Key="Ubisam.Style.NumericCell" TargetType="DataGridCell" BasedOn="{StaticResource {x:Type DataGridCell}}">
<Setter Property="HorizontalAlignment" Value="Stretch"/>
<Setter Property="HorizontalContentAlignment" Value="Right"/>
<Setter Property="TextBlock.TextAlignment" Value="Right"/>
</Style>
<!-- ══ 상태 태그 ══
표의 판정·레벨 같은 상태 열은 맨 텍스트가 아니라 태그로 그린다 (디자인 시스템 규칙).
DataGridTemplateColumn / GridViewColumn의 CellTemplate 안에서 쓴다. -->
<Style x:Key="Ubisam.Style.Tag" TargetType="Border">
<Setter Property="Background" Value="{DynamicResource Ubisam.Brush.Tag.NeutralBackground}"/>
<Setter Property="CornerRadius" Value="3"/>
<Setter Property="Padding" Value="10,3"/>
<Setter Property="HorizontalAlignment" Value="Left"/>
<Setter Property="VerticalAlignment" Value="Center"/>
</Style>
<Style x:Key="Ubisam.Style.TagText" TargetType="TextBlock">
<Setter Property="FontFamily" Value="{DynamicResource Ubisam.Font.Body}"/>
<Setter Property="FontSize" Value="11"/>
<Setter Property="Foreground" Value="{DynamicResource Ubisam.Brush.Tag.NeutralForeground}"/>
</Style>
<!-- 정상·완료: accent 옅은 채움 -->
<Style x:Key="Ubisam.Style.Tag.Accent" TargetType="Border" BasedOn="{StaticResource Ubisam.Style.Tag}">
<Setter Property="Background" Value="{DynamicResource Ubisam.Brush.Tag.AccentBackground}"/>
</Style>
<Style x:Key="Ubisam.Style.TagText.Accent" TargetType="TextBlock" BasedOn="{StaticResource Ubisam.Style.TagText}">
<Setter Property="Foreground" Value="{DynamicResource Ubisam.Brush.Tag.AccentForeground}"/>
</Style>
<!-- 주의·재검: 외곽선만 (예외 행이 한눈에 걸리도록) -->
<Style x:Key="Ubisam.Style.Tag.Outline" TargetType="Border" BasedOn="{StaticResource Ubisam.Style.Tag}">
<Setter Property="Background" Value="Transparent"/>
<Setter Property="BorderBrush" Value="{DynamicResource Ubisam.Brush.Accent}"/>
<Setter Property="BorderThickness" Value="1"/>
</Style>
<Style x:Key="Ubisam.Style.TagText.Outline" TargetType="TextBlock" BasedOn="{StaticResource Ubisam.Style.TagText}">
<Setter Property="Foreground" Value="{DynamicResource Ubisam.Brush.Accent}"/>
</Style>
<!-- ══ Slider ══
트랙은 얇은 홈, 채워진 구간만 accent. 썸은 각진 세로 막대(다이얼이 아니라 계기 슬라이드). -->
<Style x:Key="Ubisam.Style.SliderThumb" TargetType="Thumb">
<Setter Property="Focusable" Value="False"/>
<Setter Property="Cursor" Value="Hand"/>
<Setter Property="Template">
<Setter.Value>
<ControlTemplate TargetType="Thumb">
<Border x:Name="Bd"
Width="10" Height="20"
Background="{DynamicResource Ubisam.Brush.Accent.Fill}"
BorderBrush="{DynamicResource Ubisam.Brush.Accent}"
BorderThickness="1"
SnapsToDevicePixels="True"/>
<ControlTemplate.Triggers>
<Trigger Property="IsMouseOver" Value="True">
<Setter TargetName="Bd" Property="Background" Value="{DynamicResource Ubisam.Brush.Accent}"/>
</Trigger>
<Trigger Property="IsDragging" Value="True">
<Setter TargetName="Bd" Property="Background" Value="{DynamicResource Ubisam.Brush.Accent}"/>
</Trigger>
</ControlTemplate.Triggers>
</ControlTemplate>
</Setter.Value>
</Setter>
</Style>
<Style TargetType="Slider">
<Setter Property="MinHeight" Value="24"/>
<Setter Property="Foreground" Value="{DynamicResource Ubisam.Brush.Accent}"/>
<Setter Property="Template">
<Setter.Value>
<ControlTemplate TargetType="Slider">
<Grid VerticalAlignment="Center">
<!-- 트랙 홈 -->
<Border Height="4"
Background="{DynamicResource Ubisam.Brush.Input.DisabledBackground}"
BorderBrush="{DynamicResource Ubisam.Brush.Input.Border}"
BorderThickness="1"
VerticalAlignment="Center"
SnapsToDevicePixels="True"/>
<Track x:Name="PART_Track">
<Track.DecreaseRepeatButton>
<RepeatButton Command="Slider.DecreaseLarge"
Focusable="False"
Height="4"
VerticalAlignment="Center">
<RepeatButton.Template>
<ControlTemplate TargetType="RepeatButton">
<!-- 채워진 구간 -->
<Border Background="{DynamicResource Ubisam.Brush.Accent}"
Height="4"
VerticalAlignment="Center"
SnapsToDevicePixels="True"/>
</ControlTemplate>
</RepeatButton.Template>
</RepeatButton>
</Track.DecreaseRepeatButton>
<Track.IncreaseRepeatButton>
<RepeatButton Command="Slider.IncreaseLarge" Focusable="False">
<RepeatButton.Template>
<ControlTemplate TargetType="RepeatButton">
<Border Background="Transparent"/>
</ControlTemplate>
</RepeatButton.Template>
</RepeatButton>
</Track.IncreaseRepeatButton>
<Track.Thumb>
<Thumb Style="{StaticResource Ubisam.Style.SliderThumb}"/>
</Track.Thumb>
</Track>
</Grid>
<ControlTemplate.Triggers>
<Trigger Property="IsEnabled" Value="False">
<Setter Property="Opacity" Value="0.45"/>
</Trigger>
</ControlTemplate.Triggers>
</ControlTemplate>
</Setter.Value>
</Setter>
</Style>
<!-- ══ ScrollBar ══
폭 10의 얇은 막대. 트랙은 비우고 썸만 회색 — hover에서 accent로. 화살표 버튼은 없앤다. -->
<Style x:Key="Ubisam.Style.ScrollThumb" TargetType="Thumb">
<Setter Property="Focusable" Value="False"/>
<Setter Property="Template">
<Setter.Value>
<ControlTemplate TargetType="Thumb">
<Border x:Name="Bd"
Background="{DynamicResource Ubisam.Brush.Input.BorderHover}"
SnapsToDevicePixels="True"/>
<ControlTemplate.Triggers>
<Trigger Property="IsMouseOver" Value="True">
<Setter TargetName="Bd" Property="Background" Value="{DynamicResource Ubisam.Brush.Accent}"/>
</Trigger>
<Trigger Property="IsDragging" Value="True">
<Setter TargetName="Bd" Property="Background" Value="{DynamicResource Ubisam.Brush.Accent}"/>
</Trigger>
</ControlTemplate.Triggers>
</ControlTemplate>
</Setter.Value>
</Setter>
</Style>
<Style TargetType="ScrollBar">
<Setter Property="Background" Value="Transparent"/>
<Setter Property="Width" Value="10"/>
<Setter Property="MinWidth" Value="10"/>
<Setter Property="Template">
<Setter.Value>
<ControlTemplate TargetType="ScrollBar">
<Grid Background="{TemplateBinding Background}">
<Track x:Name="PART_Track" IsDirectionReversed="True">
<Track.DecreaseRepeatButton>
<RepeatButton Command="ScrollBar.PageUpCommand" Focusable="False">
<RepeatButton.Template>
<ControlTemplate TargetType="RepeatButton">
<Border Background="Transparent"/>
</ControlTemplate>
</RepeatButton.Template>
</RepeatButton>
</Track.DecreaseRepeatButton>
<Track.IncreaseRepeatButton>
<RepeatButton Command="ScrollBar.PageDownCommand" Focusable="False">
<RepeatButton.Template>
<ControlTemplate TargetType="RepeatButton">
<Border Background="Transparent"/>
</ControlTemplate>
</RepeatButton.Template>
</RepeatButton>
</Track.IncreaseRepeatButton>
<Track.Thumb>
<Thumb Style="{StaticResource Ubisam.Style.ScrollThumb}" Margin="3,0"/>
</Track.Thumb>
</Track>
</Grid>
<ControlTemplate.Triggers>
<Trigger Property="Orientation" Value="Horizontal">
<Setter Property="Height" Value="10"/>
<Setter Property="MinHeight" Value="10"/>
<Setter Property="Width" Value="Auto"/>
<Setter Property="MinWidth" Value="0"/>
<Setter TargetName="PART_Track" Property="IsDirectionReversed" Value="False"/>
<Setter TargetName="PART_Track" Property="Orientation" Value="Horizontal"/>
</Trigger>
</ControlTemplate.Triggers>
</ControlTemplate>
</Setter.Value>
</Setter>
</Style>
</ResourceDictionary>

View File

@ -0,0 +1,107 @@
<!-- Ubisam Base 아이콘 세트 — Lucide(https://lucide.dev) 원본 경로를 WPF Geometry 문법으로 옮긴 것.
24×24 좌표계, 스트로크 렌더링(StrokeThickness 1.5, 둥근 캡). Path의 Fill은 비우고 Stroke만 쓴다.
SVG의 <circle>/<rect>/<ellipse>는 호(A)와 직선으로 변환돼 있다.
아이콘을 추가할 때는 Lucide에서 같은 규칙(24px, 1.5)으로 가져와 여기에 Geometry 한 줄로 등록하고,
AddMain의 icon 인자에 키 뒷부분("Home" 등)을 넘긴다. -->
<ResourceDictionary xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml">
<!-- 홈 · lucide/house -->
<Geometry x:Key="Ubisam.Icon.Home">M15 21v-8a1 1 0 0 0-1-1h-4a1 1 0 0 0-1 1v8 M3 10a2 2 0 0 1 .709-1.528l7-5.999a2 2 0 0 1 2.582 0l7 5.999A2 2 0 0 1 21 10v9a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z</Geometry>
<!-- 모니터링·그래프 · lucide/activity -->
<Geometry x:Key="Ubisam.Icon.Monitor">M22 12h-2.48a2 2 0 0 0-1.93 1.46l-2.35 8.36a.25.25 0 0 1-.48 0L9.24 2.18a.25.25 0 0 0-.48 0l-2.35 8.36A2 2 0 0 1 4.49 12H2</Geometry>
<!-- 통계·리포트 · lucide/chart-column -->
<Geometry x:Key="Ubisam.Icon.Chart">M3 3v16a2 2 0 0 0 2 2h16 M18 17V9 M13 17V5 M8 17v-3</Geometry>
<!-- 유지보수 · lucide/clipboard-check -->
<Geometry x:Key="Ubisam.Icon.Maintenance">M9,2 H15 A1,1 0 0 1 16,3 V5 A1,1 0 0 1 15,6 H9 A1,1 0 0 1 8,5 V3 A1,1 0 0 1 9,2 Z M16 4h2a2 2 0 0 1 2 2v14a2 2 0 0 1-2 2H6a2 2 0 0 1-2-2V6a2 2 0 0 1 2-2h2 m9 14 2 2 4-4</Geometry>
<!-- 설정 · lucide/sliders-horizontal -->
<Geometry x:Key="Ubisam.Icon.Settings">M21 4h-7 M10 4H3 M21 12h-9 M8 12H3 M21 20h-5 M12 20H3 M14 2v4 M8 10v4 M16 18v4</Geometry>
<!-- 알람 · lucide/bell -->
<Geometry x:Key="Ubisam.Icon.Alarm">M10.268 21a2 2 0 0 0 3.464 0 M3.262 15.326A1 1 0 0 0 4 17h16a1 1 0 0 0 .74-1.673C19.41 13.956 18 12.499 18 8A6 6 0 0 0 6 8c0 4.499-1.411 5.956-2.738 7.326</Geometry>
<!-- 로그 · lucide/list -->
<Geometry x:Key="Ubisam.Icon.Log">M3 5h.01 M3 12h.01 M3 19h.01 M8 5h13 M8 12h13 M8 19h13</Geometry>
<!-- 문서·리포트 · lucide/file-text -->
<Geometry x:Key="Ubisam.Icon.Document">M15 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V7z M14 2v4a2 2 0 0 0 2 2h4 M10 9H8 M16 13H8 M16 17H8</Geometry>
<!-- 데이터 · lucide/database -->
<Geometry x:Key="Ubisam.Icon.Data">M3,5 A9,3 0 0 1 21,5 A9,3 0 0 1 3,5 Z M3 5V19A9 3 0 0 0 21 19V5 M3 12A9 3 0 0 0 21 12</Geometry>
<!-- 전원 · lucide/power -->
<Geometry x:Key="Ubisam.Icon.Power">M12 2v10 M18.4 6.6a9 9 0 1 1-12.77.04</Geometry>
<!-- 온도 · lucide/thermometer -->
<Geometry x:Key="Ubisam.Icon.Temperature">M14 4v10.54a4 4 0 1 1-4 0V4a2 2 0 0 1 4 0z</Geometry>
<!-- 압력·게이지 · lucide/gauge -->
<Geometry x:Key="Ubisam.Icon.Gauge">m12 14 4-4 M3.34 19a10 10 0 1 1 17.32 0</Geometry>
<!-- 운전 · lucide/play -->
<Geometry x:Key="Ubisam.Icon.Run">M6 3l14 9-14 9z</Geometry>
<!-- 정지 · lucide/square -->
<Geometry x:Key="Ubisam.Icon.Stop">M5,3 H19 A2,2 0 0 1 21,5 V19 A2,2 0 0 1 19,21 H5 A2,2 0 0 1 3,19 V5 A2,2 0 0 1 5,3 Z</Geometry>
<!-- 리셋 · lucide/rotate-cw -->
<Geometry x:Key="Ubisam.Icon.Reset">M21 12a9 9 0 1 1-9-9c2.52 0 4.93 1 6.74 2.74L21 8 M21 3v5h-5</Geometry>
<!-- 계정 · lucide/user -->
<Geometry x:Key="Ubisam.Icon.Account">M19 21v-2a4 4 0 0 0-4-4H9a4 4 0 0 0-4 4v2 M8,7 A4,4 0 0 1 16,7 A4,4 0 0 1 8,7 Z</Geometry>
<!-- 권한·보안 · lucide/lock -->
<Geometry x:Key="Ubisam.Icon.Lock">M5,11 H19 A2,2 0 0 1 21,13 V20 A2,2 0 0 1 19,22 H5 A2,2 0 0 1 3,20 V13 A2,2 0 0 1 5,11 Z M7 11V7a5 5 0 0 1 10 0v4</Geometry>
<!-- 내보내기 · lucide/download -->
<Geometry x:Key="Ubisam.Icon.Export">M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4 M7 10l5 5 5-5 M12 15V3</Geometry>
<!-- 불러오기 · lucide/upload -->
<Geometry x:Key="Ubisam.Icon.Import">M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4 M17 8l-5-5-5 5 M12 3v12</Geometry>
<!-- 검색 · lucide/search -->
<Geometry x:Key="Ubisam.Icon.Search">m21 21-4.34-4.34 M3,11 A8,8 0 0 1 19,11 A8,8 0 0 1 3,11 Z</Geometry>
<!-- 일정·이력 · lucide/calendar -->
<Geometry x:Key="Ubisam.Icon.Calendar">M8 2v4 M16 2v4 M5,4 H19 A2,2 0 0 1 21,6 V20 A2,2 0 0 1 19,22 H5 A2,2 0 0 1 3,20 V6 A2,2 0 0 1 5,4 Z M3 10h18</Geometry>
<!-- 시간·주기 · lucide/clock -->
<Geometry x:Key="Ubisam.Icon.Clock">M2,12 A10,10 0 0 1 22,12 A10,10 0 0 1 2,12 Z M12 6v6l4 2</Geometry>
<!-- 통신·연결 · lucide/link -->
<Geometry x:Key="Ubisam.Icon.Link">M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71 M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71</Geometry>
<!-- 경고 · lucide/triangle-alert -->
<Geometry x:Key="Ubisam.Icon.Warning">m21.73 18-8-14a2 2 0 0 0-3.48 0l-8 14A2 2 0 0 0 4 21h16a2 2 0 0 0 1.73-3 M12 9v4 M12 17h.01</Geometry>
<!-- 검사 결과 · lucide/circle-check -->
<Geometry x:Key="Ubisam.Icon.Pass">M2,12 A10,10 0 0 1 22,12 A10,10 0 0 1 2,12 Z m9 12 2 2 4-4</Geometry>
<!-- 파일·폴더 · lucide/folder -->
<Geometry x:Key="Ubisam.Icon.Folder">M20 20a2 2 0 0 0 2-2V8a2 2 0 0 0-2-2h-7.9a2 2 0 0 1-1.69-.9L9.6 3.9A2 2 0 0 0 7.93 3H4a2 2 0 0 0-2 2v13a2 2 0 0 0 2 2z</Geometry>
<!-- 비전·카메라 · lucide/camera -->
<Geometry x:Key="Ubisam.Icon.Vision">M14.5 4h-5L7 7H4a2 2 0 0 0-2 2v9a2 2 0 0 0 2 2h16a2 2 0 0 0 2-2V9a2 2 0 0 0-2-2h-3l-2.5-3z M9,13 A3,3 0 0 1 15,13 A3,3 0 0 1 9,13 Z</Geometry>
<!-- 제어·I/O · lucide/cpu -->
<Geometry x:Key="Ubisam.Icon.Control">M6,4 H18 A2,2 0 0 1 20,6 V18 A2,2 0 0 1 18,20 H6 A2,2 0 0 1 4,18 V6 A2,2 0 0 1 6,4 Z M10,9 H14 A1,1 0 0 1 15,10 V14 A1,1 0 0 1 14,15 H10 A1,1 0 0 1 9,14 V10 A1,1 0 0 1 10,9 Z M15 2v2 M15 20v2 M2 15h2 M2 9h2 M20 15h2 M20 9h2 M9 2v2 M9 20v2</Geometry>
<!-- 레시피 · lucide/layers -->
<Geometry x:Key="Ubisam.Icon.Recipe">M12.83 2.18a2 2 0 0 0-1.66 0L2.6 6.08a1 1 0 0 0 0 1.83l8.58 3.91a2 2 0 0 0 1.66 0l8.58-3.9a1 1 0 0 0 0-1.83z M2 12a1 1 0 0 0 .58.91l8.6 3.91a2 2 0 0 0 1.65 0l8.58-3.9A1 1 0 0 0 22 12 M2 17a1 1 0 0 0 .58.91l8.6 3.91a2 2 0 0 0 1.65 0l8.58-3.9A1 1 0 0 0 22 17</Geometry>
<!-- 모듈 목록 · lucide/layout-grid -->
<Geometry x:Key="Ubisam.Icon.Modules">M4,3 H9 A1,1 0 0 1 10,4 V9 A1,1 0 0 1 9,10 H4 A1,1 0 0 1 3,9 V4 A1,1 0 0 1 4,3 Z M15,3 H20 A1,1 0 0 1 21,4 V9 A1,1 0 0 1 20,10 H15 A1,1 0 0 1 14,9 V4 A1,1 0 0 1 15,3 Z M15,14 H20 A1,1 0 0 1 21,15 V20 A1,1 0 0 1 20,21 H15 A1,1 0 0 1 14,20 V15 A1,1 0 0 1 15,14 Z M4,14 H9 A1,1 0 0 1 10,15 V20 A1,1 0 0 1 9,21 H4 A1,1 0 0 1 3,20 V15 A1,1 0 0 1 4,14 Z</Geometry>
<!-- 안내 · lucide/circle-info -->
<Geometry x:Key="Ubisam.Icon.Info">M2,12 A10,10 0 0 1 22,12 A10,10 0 0 1 2,12 Z M12,16 L12,12 M12,7.99 L12,8.01</Geometry>
<!-- 확인 질문 · lucide/circle-help -->
<Geometry x:Key="Ubisam.Icon.Question">M2,12 A10,10 0 0 1 22,12 A10,10 0 0 1 2,12 Z M9.09,9 A3,3 0 0 1 14.92,10 C14.92,12 11.92,13 11.92,13 M12,16.99 L12,17.01</Geometry>
<!-- 오류 · lucide/circle-x -->
<Geometry x:Key="Ubisam.Icon.Error">M2,12 A10,10 0 0 1 22,12 A10,10 0 0 1 2,12 Z M15,9 L9,15 M9,9 L15,15</Geometry>
</ResourceDictionary>

View File

@ -0,0 +1,41 @@
<!-- 회사 로고 — 원본 마크(둥근 사각 타일 + 동심 아치 2개 + 점)를 벡터로 재구성.
ShellWindow.xaml의 로고 자리에서 <Image Source="{StaticResource Ubisam.Logo}"/>로 쓰거나,
단색 배경 위에 마크만 얹으려면 Ubisam.Logo.Mark(Geometry)와 Ubisam.Logo.Dot을 직접 그린다. -->
<ResourceDictionary xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml">
<!-- 아치 2개 (스트로크로 그린다: StrokeThickness 6.2, 64x64 좌표계) -->
<Geometry x:Key="Ubisam.Logo.Arcs">M20,32.5 A12.5,12.5 0 0 1 32.5,45 M20,20.5 A24.5,24.5 0 0 1 44.5,45</Geometry>
<!-- 점 (채움) -->
<Geometry x:Key="Ubisam.Logo.Dot">M15.1,45 A4.9,4.9 0 0 1 24.9,45 A4.9,4.9 0 0 1 15.1,45 Z</Geometry>
<LinearGradientBrush x:Key="Ubisam.Logo.TileBrush" StartPoint="0,0" EndPoint="1,1">
<GradientStop Offset="0" Color="#FF56ADF7"/>
<GradientStop Offset="1" Color="#FF1D82E8"/>
</LinearGradientBrush>
<SolidColorBrush x:Key="Ubisam.Logo.MarkBrush" Color="#FFEDF2F4"/>
<DrawingImage x:Key="Ubisam.Logo">
<DrawingImage.Drawing>
<DrawingGroup>
<!-- 둥근 사각 타일 -->
<GeometryDrawing Brush="{StaticResource Ubisam.Logo.TileBrush}">
<GeometryDrawing.Geometry>
<RectangleGeometry Rect="0,0,64,64" RadiusX="15" RadiusY="15"/>
</GeometryDrawing.Geometry>
</GeometryDrawing>
<!-- 아치 -->
<GeometryDrawing Geometry="{StaticResource Ubisam.Logo.Arcs}">
<GeometryDrawing.Pen>
<Pen Brush="{StaticResource Ubisam.Logo.MarkBrush}" Thickness="6.2" StartLineCap="Flat" EndLineCap="Flat"/>
</GeometryDrawing.Pen>
</GeometryDrawing>
<!-- 점 -->
<GeometryDrawing Brush="{StaticResource Ubisam.Logo.MarkBrush}"
Geometry="{StaticResource Ubisam.Logo.Dot}"/>
</DrawingGroup>
</DrawingImage.Drawing>
</DrawingImage>
</ResourceDictionary>

View File

@ -0,0 +1,273 @@
<ResourceDictionary xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:conv="clr-namespace:UbisamBase.Core.Converters"
xmlns:sys="clr-namespace:System;assembly=mscorlib">
<ResourceDictionary.MergedDictionaries>
<ResourceDictionary Source="pack://application:,,,/UbisamBase.Core;component/Themes/Icons.xaml"/>
<ResourceDictionary Source="pack://application:,,,/UbisamBase.Core;component/Themes/Controls.xaml"/>
</ResourceDictionary.MergedDictionaries>
<conv:BoolToVisibilityConverter x:Key="BoolToVis"/>
<conv:FirstCharacterConverter x:Key="FirstCharConverter"/>
<conv:ToastTypeToBrushConverter x:Key="ToastTypeToBrush"/>
<conv:IconKeyToGeometryConverter x:Key="IconToGeometry"/>
<!-- ComboBox 드롭다운 그림자 -->
<DropShadowEffect x:Key="Ubisam.Effect.Popup" BlurRadius="12" ShadowDepth="3" Direction="270" Opacity="0.22" Color="#000000"/>
<!-- 타이포: Barlow가 설치돼 있으면 쓰고, 없으면 Segoe UI로 폴백 -->
<FontFamily x:Key="Ubisam.Font.Heading">Barlow Condensed, Segoe UI</FontFamily>
<FontFamily x:Key="Ubisam.Font.Body">Barlow, Segoe UI</FontFamily>
<!-- 좌/우측 네비게이션 아이템: 아이콘 위 + 라벨 아래, 선택 시 좌측 accent 바 -->
<Style x:Key="Ubisam.Style.SidebarItem" TargetType="ListBoxItem">
<Setter Property="Padding" Value="14,20"/>
<Setter Property="Foreground" Value="{DynamicResource Ubisam.Brush.Sidebar.Foreground}"/>
<Setter Property="Cursor" Value="Hand"/>
<Setter Property="HorizontalContentAlignment" Value="Center"/>
<Setter Property="Template">
<Setter.Value>
<ControlTemplate TargetType="ListBoxItem">
<Border x:Name="Bd"
Background="Transparent"
BorderThickness="3,0,0,0"
BorderBrush="Transparent"
Padding="{TemplateBinding Padding}">
<StackPanel Orientation="Vertical" HorizontalAlignment="Center">
<TextBlock Text="{Binding Icon}"
FontFamily="Segoe Fluent Icons, Segoe MDL2 Assets"
FontSize="26"
HorizontalAlignment="Center"/>
<TextBlock Text="{Binding Title}"
FontSize="13"
Margin="0,8,0,0"
HorizontalAlignment="Center"
TextWrapping="Wrap"
TextAlignment="Center"
MaxWidth="100"/>
</StackPanel>
</Border>
<ControlTemplate.Triggers>
<Trigger Property="IsSelected" Value="True">
<Setter TargetName="Bd" Property="BorderBrush" Value="{DynamicResource Ubisam.Brush.Accent}"/>
<Setter TargetName="Bd" Property="Background" Value="{DynamicResource Ubisam.Brush.Sidebar.SelectedBackground}"/>
<Setter Property="Foreground" Value="{DynamicResource Ubisam.Brush.Sidebar.SelectedForeground}"/>
<Setter Property="FontWeight" Value="SemiBold"/>
</Trigger>
<Trigger Property="IsMouseOver" Value="True">
<Setter TargetName="Bd" Property="Background" Value="{DynamicResource Ubisam.Brush.Sidebar.HoverBackground}"/>
</Trigger>
</ControlTemplate.Triggers>
</ControlTemplate>
</Setter.Value>
</Setter>
</Style>
<Style x:Key="Ubisam.Style.SidebarBar.Vertical" TargetType="ListBox">
<Setter Property="Background" Value="{DynamicResource Ubisam.Brush.Sidebar.Background}"/>
<Setter Property="BorderThickness" Value="0,0,1,0"/>
<Setter Property="BorderBrush" Value="{DynamicResource Ubisam.Brush.SubTabBar.Border}"/>
<Setter Property="Padding" Value="0,20"/>
<Setter Property="ItemContainerStyle" Value="{StaticResource Ubisam.Style.SidebarItem}"/>
<Setter Property="ScrollViewer.HorizontalScrollBarVisibility" Value="Disabled"/>
<Setter Property="ScrollViewer.VerticalScrollBarVisibility" Value="Auto"/>
<Setter Property="ItemsPanel">
<Setter.Value>
<ItemsPanelTemplate>
<StackPanel Orientation="Vertical"/>
</ItemsPanelTemplate>
</Setter.Value>
</Setter>
</Style>
<!-- 하단 네비게이션 아이템: 아이콘 위 + 라벨 아래. 칸 구분선도, 선택 배경 틴트도 없다.
선택 표시는 아래 SidebarBar.Horizontal 템플릿의 인디케이터 바가 미끄러져 오면서 담당한다. -->
<Style x:Key="Ubisam.Style.SidebarItem.Horizontal" TargetType="ListBoxItem">
<Setter Property="Padding" Value="30,13,30,14"/>
<Setter Property="Foreground" Value="{DynamicResource Ubisam.Brush.Sidebar.Foreground}"/>
<Setter Property="FontFamily" Value="{StaticResource Ubisam.Font.Body}"/>
<Setter Property="FontSize" Value="13.5"/>
<Setter Property="FontWeight" Value="Medium"/>
<Setter Property="Cursor" Value="Hand"/>
<Setter Property="Template">
<Setter.Value>
<ControlTemplate TargetType="ListBoxItem">
<Border x:Name="Bd"
Background="Transparent"
Padding="{TemplateBinding Padding}">
<StackPanel Orientation="Vertical" HorizontalAlignment="Center" VerticalAlignment="Center">
<Path x:Name="Ico"
Data="{Binding Icon, Converter={StaticResource IconToGeometry}}"
Stroke="{Binding Foreground, RelativeSource={RelativeSource TemplatedParent}}"
StrokeThickness="1.5"
StrokeStartLineCap="Round"
StrokeEndLineCap="Round"
StrokeLineJoin="Round"
Width="22" Height="22"
Stretch="Uniform"
Margin="0,0,0,7"
HorizontalAlignment="Center"/>
<TextBlock Text="{Binding Title}"
HorizontalAlignment="Center"
TextTrimming="CharacterEllipsis"/>
</StackPanel>
</Border>
<ControlTemplate.Triggers>
<!-- icon 인자를 생략한 탭은 아이콘 자리를 아예 접어 라벨만 남긴다 -->
<DataTrigger Binding="{Binding Icon, Converter={StaticResource IconToGeometry}}" Value="{x:Null}">
<Setter TargetName="Ico" Property="Visibility" Value="Collapsed"/>
</DataTrigger>
<Trigger Property="IsSelected" Value="True">
<Setter Property="Foreground" Value="{DynamicResource Ubisam.Brush.Sidebar.SelectedForeground}"/>
</Trigger>
<MultiTrigger>
<MultiTrigger.Conditions>
<Condition Property="IsMouseOver" Value="True"/>
<Condition Property="IsSelected" Value="False"/>
</MultiTrigger.Conditions>
<Setter TargetName="Bd" Property="Background" Value="{DynamicResource Ubisam.Brush.Sidebar.HoverBackground}"/>
</MultiTrigger>
</ControlTemplate.Triggers>
</ControlTemplate>
</Setter.Value>
</Setter>
</Style>
<!-- 하단 탭 바: 탭을 가운데 모으고, 바닥에 accent 인디케이터를 하나 둔다.
인디케이터의 위치·너비는 ShellWindow.xaml.cs가 선택 변경 시 애니메이션으로 옮긴다. -->
<Style x:Key="Ubisam.Style.SidebarBar.Horizontal" TargetType="ListBox">
<Setter Property="Background" Value="{DynamicResource Ubisam.Brush.Sidebar.Background}"/>
<Setter Property="BorderThickness" Value="0,1,0,0"/>
<Setter Property="BorderBrush" Value="{DynamicResource Ubisam.Brush.SubTabBar.Border}"/>
<Setter Property="Padding" Value="0"/>
<Setter Property="ItemContainerStyle" Value="{StaticResource Ubisam.Style.SidebarItem.Horizontal}"/>
<Setter Property="ScrollViewer.HorizontalScrollBarVisibility" Value="Disabled"/>
<Setter Property="ScrollViewer.VerticalScrollBarVisibility" Value="Disabled"/>
<Setter Property="ItemsPanel">
<Setter.Value>
<ItemsPanelTemplate>
<StackPanel Orientation="Horizontal" HorizontalAlignment="Center"/>
</ItemsPanelTemplate>
</Setter.Value>
</Setter>
<Setter Property="Template">
<Setter.Value>
<ControlTemplate TargetType="ListBox">
<Border Background="{TemplateBinding Background}"
BorderBrush="{TemplateBinding BorderBrush}"
BorderThickness="{TemplateBinding BorderThickness}">
<Grid>
<ItemsPresenter/>
<Border x:Name="SelectionIndicator"
Height="3"
Width="0"
HorizontalAlignment="Left"
VerticalAlignment="Bottom"
Background="{DynamicResource Ubisam.Brush.Accent}">
<Border.RenderTransform>
<TranslateTransform x:Name="SelectionIndicatorShift" X="0"/>
</Border.RenderTransform>
</Border>
</Grid>
</Border>
</ControlTemplate>
</Setter.Value>
</Setter>
</Style>
<!-- 하단 서브탭: 각진 세그먼트(라운드 제거) -->
<Style x:Key="Ubisam.Style.SubTabItem" TargetType="ListBoxItem">
<Setter Property="Padding" Value="22,11"/>
<Setter Property="FontSize" Value="14"/>
<Setter Property="FontFamily" Value="{StaticResource Ubisam.Font.Body}"/>
<Setter Property="Foreground" Value="{DynamicResource Ubisam.Brush.SubTabBar.Foreground}"/>
<Setter Property="Cursor" Value="Hand"/>
<Setter Property="Template">
<Setter.Value>
<ControlTemplate TargetType="ListBoxItem">
<Border x:Name="Bd"
Background="Transparent"
BorderThickness="1,0,0,0"
BorderBrush="{DynamicResource Ubisam.Brush.SubTabBar.Border}"
Padding="{TemplateBinding Padding}">
<ContentPresenter HorizontalAlignment="Center" VerticalAlignment="Center"/>
</Border>
<ControlTemplate.Triggers>
<Trigger Property="IsSelected" Value="True">
<Setter TargetName="Bd" Property="Background" Value="{DynamicResource Ubisam.Brush.SubTabBar.SelectedBackground}"/>
<Setter Property="Foreground" Value="{DynamicResource Ubisam.Brush.SubTabBar.SelectedForeground}"/>
<Setter Property="FontWeight" Value="SemiBold"/>
</Trigger>
<Trigger Property="IsMouseOver" Value="True">
<Setter TargetName="Bd" Property="Background" Value="{DynamicResource Ubisam.Brush.SubTabBar.HoverBackground}"/>
</Trigger>
</ControlTemplate.Triggers>
</ControlTemplate>
</Setter.Value>
</Setter>
</Style>
<Style x:Key="Ubisam.Style.SubTabBar" TargetType="ListBox">
<Setter Property="Background" Value="{DynamicResource Ubisam.Brush.SubTabBar.Background}"/>
<Setter Property="BorderThickness" Value="0,1,0,0"/>
<Setter Property="BorderBrush" Value="{DynamicResource Ubisam.Brush.SubTabBar.Border}"/>
<Setter Property="Padding" Value="0"/>
<Setter Property="ItemContainerStyle" Value="{StaticResource Ubisam.Style.SubTabItem}"/>
<Setter Property="ScrollViewer.HorizontalScrollBarVisibility" Value="Auto"/>
<Setter Property="ScrollViewer.VerticalScrollBarVisibility" Value="Disabled"/>
<Setter Property="ItemsPanel">
<Setter.Value>
<ItemsPanelTemplate>
<StackPanel Orientation="Horizontal"/>
</ItemsPanelTemplate>
</Setter.Value>
</Setter>
</Style>
<!-- 상단바의 계정/바탕화면/종료 같은 유틸리티 버튼: 각진 헤어라인 박스 -->
<Style x:Key="Ubisam.Style.ChromeButton" TargetType="Button">
<Setter Property="Padding" Value="14,7"/>
<Setter Property="FontSize" Value="13"/>
<Setter Property="FontFamily" Value="{StaticResource Ubisam.Font.Body}"/>
<Setter Property="Foreground" Value="{DynamicResource Ubisam.Brush.Sidebar.Foreground}"/>
<Setter Property="Cursor" Value="Hand"/>
<Setter Property="Template">
<Setter.Value>
<ControlTemplate TargetType="Button">
<Border x:Name="Bd"
Background="Transparent"
CornerRadius="0"
BorderThickness="1"
BorderBrush="{DynamicResource Ubisam.Brush.SubTabBar.Border}"
Padding="{TemplateBinding Padding}">
<ContentPresenter HorizontalAlignment="Center" VerticalAlignment="Center"/>
</Border>
<ControlTemplate.Triggers>
<Trigger Property="IsMouseOver" Value="True">
<Setter TargetName="Bd" Property="Background" Value="{DynamicResource Ubisam.Brush.Sidebar.HoverBackground}"/>
</Trigger>
</ControlTemplate.Triggers>
</ControlTemplate>
</Setter.Value>
</Setter>
</Style>
<!-- 빈 뷰 슬롯 배경(사선 해치). 모듈이 마운트되기 전 영역 표시용. -->
<DrawingBrush x:Key="Ubisam.Brush.SlotHatch"
TileMode="Tile"
Viewport="0,0,9,9"
ViewportUnits="Absolute">
<DrawingBrush.Drawing>
<GeometryDrawing>
<GeometryDrawing.Pen>
<Pen Thickness="1" Brush="{DynamicResource Ubisam.Brush.Slot.Hatch}"/>
</GeometryDrawing.Pen>
<GeometryDrawing.Geometry>
<LineGeometry StartPoint="0,9" EndPoint="9,0"/>
</GeometryDrawing.Geometry>
</GeometryDrawing>
</DrawingBrush.Drawing>
</DrawingBrush>
</ResourceDictionary>

Binary file not shown.

After

Width:  |  Height:  |  Size: 77 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 56 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 56 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.8 KiB

View File

@ -0,0 +1,85 @@
# 회사 로고 — 재구성본
원본(저해상도 PNG)의 구성을 그대로 따라 벡터로 다시 그렸습니다: 둥근 사각 타일 + 같은 중심의 아치 2개 + 점.
좌표계는 64×64, 타일 라운드 15, 아치 스트로크 9.75(끝은 평평하게), 마크 중심은 (14.19, 49.81)입니다.
마크는 타일의 70%를 차지하고, 좌하단으로 4.5% 광학 보정이 들어가 있습니다 — 아치 질량이 우상단에 몰려 있어 bbox를 그냥 중앙에 놓으면 우상단으로 쏠려 보입니다.
| 파일 | 용도 |
| --- | --- |
| `ubisam-logo.svg` | 기본 — 그라디언트 타일(#56ADF7 → #1D82E8) + 오프화이트 마크. 원본에 가장 가깝습니다 |
| `ubisam-logo-flat.svg` | 단색 타일(#2196F3) + 흰 마크. 작은 크기(16~24px)나 인쇄용 |
| `ubisam-logo-mono.svg` | 타일이 `currentColor` — 다크 배경에서 타일 색만 바꿔 쓸 때 |
| `ubisam-mark.svg` | 타일 없이 마크만, `currentColor`. 이미 파란 판 위에 얹을 때 |
| `../code/UbisamBase.Core/Themes/Logo.xaml` | WPF용 `DrawingImage` 리소스 (같은 도형) |
## 워드마크
첨부해 주신 원본에서 앞의 "Ubi"만 색을 바꿨습니다 — "Sam"과 마크는 손대지 않았습니다.
원본에는 글자 뒤에 **불투명한 흰 판**이 깔려 있습니다. 색만 바꾸면 어두운 배경에서 그 판이 흰 사각형으로 그대로 보이므로 걷어냈습니다.
흰 픽셀을 남기는 조건은 하나입니다 — **파란 타일 영역 안 + 파랑에 둘러싸여 있음**. 그게 마크의 아치입니다. 글자 속공간(`a`·`b`·`U`)의 흰색은 파랑에 갇혀 있어도 타일 밖이므로 투명해지고, 타일 라운드 코너 밖의 흰 판도 배경에서 도달하므로 투명해집니다. 글자 경계의 안티에일리어싱은 알파로 보존됩니다.
| 파일 | 용도 |
| --- | --- |
| `wordmark-ubi-black.png` | 밝은 배경 — Ubi #1D1F20 |
| `wordmark-ubi-white.png` | 어두운 배경 — Ubi #FFFFFF |
| `wordmark-ubi-gray.png` | 원본 그대로 (보관용 — 흰 판이 남아 있습니다) |
3533×829px입니다 — **화면에 쓸 때는 이 파일을 직접 줄이지 마세요.** 30배 축소는 획을 부수므로, `wordmark/` 폴더의 미리 축소한 파일을 쓰세요.
### 화면용 (미리 축소, 단계 리샘플)
| 파일 | 크기 |
| --- | --- |
| `wordmark/{white,black}-180.png` | 180×42 |
| `wordmark/{white,black}-240.png` | 240×56 |
| `wordmark/{white,black}-360.png` | 360×84 |
| `wordmark/{white,black}-480.png` | 480×112 |
| `wordmark/{white,black}-720.png` | 720×169 |
실제 표시 폭보다 한 단계 큰 파일을 쓰고, WPF에서는 `RenderOptions.BitmapScalingMode="HighQuality"`를 지정하세요. 자세한 내용은 `code/FIX-13.md`에 있습니다.
accent 채움 위에는 쓰지 마세요 — "Sam"이 배경에 묻힙니다. 그 경우 흰 단색 버전이 필요하니 말씀해 주세요.
## 실행 아이콘 (.ico)
| 파일 | 용도 |
| --- | --- |
| `UbisamBase.ico` | 실행 파일·작업 표시줄·바로가기 아이콘. 16 / 24 / 32 / 48 / 64 / 128 / 256px 7종을 담고 있습니다 |
| `app-icon-{크기}.png` | 개별 크기 PNG — 설치 관리자나 문서에 쓸 때 |
크기별로 형태를 다르게 최적화했습니다. 48px 이상은 원본대로 아치 2개, **24px 이하는 아치를 1개로 줄이고 두껍게** 했습니다 — 작은 크기에서 두 아치가 뭉개져 회색 덩어리로 보이는 것을 막기 위해서입니다. 16·24px은 타일이 화면을 꽉 채우고, 큰 크기는 여백을 둡니다.
### 프로젝트에 등록
1. `UbisamBase.ico`를 `src/UbisamBase.App/`에 복사
2. `.csproj`에 한 줄 추가
```xml
<PropertyGroup>
<ApplicationIcon>UbisamBase.ico</ApplicationIcon>
</PropertyGroup>
```
창 제목줄 아이콘은 `WindowStyle="None"`이라 보이지 않지만, 작업 표시줄에는 나옵니다. 명시하려면 `ShellWindow.xaml`에 추가하세요.
```xml
Icon="pack://application:,,,/UbisamBase.App;component/UbisamBase.ico"
```
빌드 후 탐색기에서 아이콘이 바뀌지 않으면 아이콘 캐시 때문입니다 — `ie4uinit.exe -show`를 실행하거나 재로그인하세요.
## WPF에서 쓰기
`ShellStyles.xaml`의 머지 목록에 `Logo.xaml`을 추가하고, `ShellWindow.xaml`의 로고 자리(accent 사각형 + 첫 글자)를 이미지로 교체하세요.
```xml
<Image Source="{StaticResource Ubisam.Logo}" Width="30" Height="30" VerticalAlignment="Center"/>
```
## 주의
- 원본이 저해상도라 라운드 반경·스트로크 두께·마크 여백은 눈으로 맞춘 근사값입니다. 원본 벡터(AI/SVG) 파일이 있으면 그걸 쓰는 게 정확합니다.
- 작은 크기에서는 그라디언트가 뭉개져 보이므로 `flat` 버전을 쓰세요.
- 타일 없이 흰 배경에 마크만 놓으면 대비가 부족합니다 — 마크만 쓸 때는 accent 색을 지정하세요.

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 8.0 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 8.0 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 8.2 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 8.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 220 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 222 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 230 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 19 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 26 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 37 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 36 KiB

BIN
.design/logo/UbisamBase.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 77 KiB

BIN
.design/logo/a-256.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 56 KiB

Some files were not shown because too many files have changed in this diff Show More