# UbisamBase 플랫폼 사용 가이드 (AI/개발자 참고용) 이 문서는 다른 AI 세션이나 개발자가 이 플랫폼으로 새 프로그램을 만들거나 기존 코드를 이식할 때 빠르게 참고할 수 있도록 정리한 기술 레퍼런스입니다. 사람이 읽는 슬라이드 버전은 `doc/UbisamBase_플랫폼_사용_매뉴얼.pptx`를 참고하세요. ## 0. 구조 ``` UbisamBase.Core 공통 서비스 + Shell UI 라이브러리 (여기 손대면 플랫폼 전체가 바뀜) UbisamBase.Shell 고정 호스트. 같은 폴더의 IAppSetup 구현 dll을 리플렉션으로 로드 UbisamBase.Launcher 실행 진입점. 실행할 때마다 D:\UbisamPlatform\bin을 자기 폴더로 복사한 뒤 Shell을 같은 프로세스 안에서 띄운다 (F5도, 사용자 실행도 이걸로 시작) 소비 프로젝트 UbisamBase.Core를 참조하고 IAppSetup만 구현하는 실제 프로그램 ``` **저장소 위치**: Core/Shell 소스는 `D:\업무\03_개발\Source\용접기\08_UbisamPlatform` 저장소에 있다. 소비 프로젝트(예제 `UbisamBase.App`)는 `08_UbisamBase` 저장소에 있다. Core/Shell을 참조하는 방식(모든 프로그램이 D드라이브 배포 폴더 하나를 본다)의 전체 설명은 10-2 참고 — 아래는 그 요약이자, 새 프로젝트를 만드는 순간부터 바로 따라 할 수 있는 실전 절차다. --- ## 0-1. 새 소비 프로젝트 만들기 — 생성 직후 플랫폼 연결 ### 프로젝트 생성 버전 / 설정 이 플랫폼은 **.NET Framework 4.8(`net48`) 전용**이다. Visual Studio의 "새 프로젝트 만들기" 마법사로 시작한다면: | 선택 항목 | 값 | | --- | --- | | 대상 프레임워크 | **.NET Framework 4.8** (netcoreapp/net6+ 아님) | | 프로젝트 형식 | **클래스 라이브러리** (WPF 앱 템플릿 아님 — 실행은 `UbisamBase.Launcher.exe`가 하고, 이 프로젝트는 그 안에서 Shell이 리플렉션으로 로드하는 dll일 뿐이다) | | 플랫폼 | **Any CPU만** (``에 x64를 넣지 않는다 — 출력 폴더가 `bin\x64\...`로 바뀌어 F5 실행 경로와 어긋난다) | | SDK 스타일 | 새 SDK 스타일 csproj (``) | VS의 클래스 라이브러리(.NET Framework) 템플릿에는 WPF 활성화 옵션이 따로 없고, 아래에 정리한 `CopyPlatformRuntime` 타겟 등 이 플랫폼 특유의 설정도 마법사가 만들어주지 않는다. 그래서 실무적으로는 둘 중 하나를 쓴다: - **(권장) 기존 소비 프로젝트를 복사해서 시작** — `08_UbisamBase\src\UbisamBase.App` 폴더 전체를 복사하고 폴더/파일 이름, `AssemblyName`/`RootNamespace`만 바꾼다. 아래 csproj의 모든 설정이 이미 정답 상태로 들어있다. - **처음부터 만들기** — 빈 폴더에 아래 `.csproj`를 그대로 붙여넣는다. ### `.csproj` 템플릿 ```xml Library net48 true latest enable MyApp MyApp false D:\UbisamPlatform\bin\ Program $(MSBuildProjectDirectory)\bin\$(Configuration)\$(TargetFramework)\UbisamBase.Launcher.exe $(MSBuildProjectDirectory)\bin\$(Configuration)\$(TargetFramework)\ true $(UbisamPlatformDir)UbisamBase.Core.dll false $(UbisamPlatformDir)Microsoft.Extensions.DependencyInjection.Abstractions.dll false <_PlatformFiles Include="$(UbisamPlatformDir)**\*.*" /> ``` ### 확인 순서 1. `dotnet build`(또는 VS 빌드) — 출력 폴더 `bin\Debug\net48\`(※ `bin\x64\...`가 아니어야 함)에 `UbisamBase.Launcher.exe`, `UbisamBase.Launcher.exe.config`, `UbisamBase.Shell.exe`, `UbisamBase.Core.dll`, 이 프로젝트의 dll이 같이 생겼는지 확인. 2. 아직 `IAppSetup`을 구현하지 않았으면 F5 시 "IAppSetup을 구현한 dll을 … 찾지 못했습니다" 예외로 멈춘다 — 이 메시지가 나오면 참조·복사·실행 경로 연결은 성공한 것이다. 3. 다음(0-2)에서 `IAppSetup`을 구현하면 실제 화면이 붙는다. 4. F5로 뜬 상태에서 작업 관리자를 보면 **`UbisamBase.Launcher` 프로세스 하나만** 있어야 한다. `UbisamBase.Shell`이 별도 프로세스로 따로 떠 있으면 옛 Launcher(별도 프로세스 방식)라서 디버거가 안 붙고 중단점이 안 걸린다 → `D:\UbisamPlatform\bin`을 최신 배포본으로 교체. 5. (선택) 이 저장소에 `.slnx` 솔루션이 있다면 ``를 추가해 VS 솔루션 탐색기에 노출시킨다. 이때 `` 같은 x64 구성은 넣지 않는다. ### 자주 겪는 실행 오류 | 증상 | 원인 | 조치 | | --- | --- | --- | | F5 시 "`UbisamBase.Launcher.exe` 프로그램을 시작할 수 없습니다 / 지정된 파일을 찾을 수 없습니다" | `StartProgram`에 `$(TargetDir)`를 썼다 — PropertyGroup 시점엔 비어 있어 파일명만 남는다 | 위 템플릿처럼 `$(MSBuildProjectDirectory)\bin\$(Configuration)\$(TargetFramework)\` 절대경로로 | | 빌드 출력이 `bin\x64\Debug\net48\`로 나온다 | csproj ``에 x64가 들어갔거나 솔루션이 x64로 매핑됨 | csproj에서 `` 삭제, `.slnx`의 x64 `Platform` 항목 삭제, 구성 관리자 활성 플랫폼 = Any CPU | | VS 상단에 "알 수 없는 프로젝트 구성 매핑이 있습니다" 배너 | `.slnx`에 프로젝트에 없는 플랫폼(x64) 매핑이 남음 | `.slnx`에서 해당 `` 삭제 후 VS 재시작 | | 켜졌다가 꺼지고 새 창으로 다시 뜬다 / 중단점이 안 걸린다 | 배포본의 Launcher가 옛 버전(Shell을 별도 프로세스로 띄움) | `D:\UbisamPlatform\bin` 최신화 후 재빌드 | | 실행 직후 창 없이 조용히 종료 (이벤트 뷰어에 `FileLoadException`) | `UbisamBase.Launcher.exe.config`에 바인딩 리디렉트가 없음(옛 배포본) | `D:\UbisamPlatform\bin` 최신화 — 플랫폼을 빌드하면 Shell 설정이 Launcher 설정으로 복사된다(다른 PC면 최신 배포 폴더를 통째로 다시 복사) | | 컴파일 에러 "…형식이 없습니다", "…정의가 포함되어 있지 않습니다" | 코드가 기대하는 플랫폼 API가 배포본에 없다 | 배포본 최신화. 그래도 없으면 플랫폼에 추가할지, 코드에서 뺄지 결정 | --- ## 0-2. `IAppSetup` 구현 새 프로그램 = `IAppSetup` 구현 클래스 하나 + 필요한 View/ViewModel. 나머지(테마, 네비게이션, 설정 저장, 로그, 대화상자, 토스트, 잠금 등)는 전부 플랫폼이 제공합니다. ```csharp public class AppSetup : IAppSetup { public string GetAppName() => "내 프로그램 이름"; // 상단 브랜딩 바에 표시 public void RegisterViews(ITabManager manager) { manager.AddMain(Icon.Home, ViewIds.Home, "홈"); manager.AddSub(ViewIds.Home, "상세"); } public void RegisterSettings(ITabManager manager) { manager.AddSettingsSub(TabManager.SettingsTabKey, "내 설정"); } public void RegisterServices(IServiceCollection services) { services.AddSingleton(); // 기존 코드를 여기서 DI에 등록 } } ``` `AddMain`/`AddSub`로 등록한 View는 DI가 자동으로 ViewModel을 만들어 `DataContext`에 연결합니다. ViewModel 생성자에 필요한 서비스(플랫폼 제공이든 RegisterServices로 직접 등록한 것이든)를 선언하기만 하면 주입됩니다 — `ActivatorUtilities.CreateInstance` 기반이라 별도 배선이 필요 없습니다. --- ## 1. 설정 화면 ### 1-1. 자동 생성 (View 없이) ```csharp public class MySettings { [Category("장비")] [DisplayName("측정값 개수")] public int MeasureCount { get; set; } [Category("장비")] public bool UseAutoSave { get; set; } [Category("데이터")] public ObservableCollection Rows { get; set; } = new(); } manager.AddSettingsSub(TabManager.SettingsTabKey, "내 설정"); ``` - 프로퍼티 타입 → 컨트롤 자동 매핑: `bool`→체크박스, `int/long/short`→정수 텍스트박스, `float/double/decimal`→소수 텍스트박스, `DateTime`→달력, `string`→텍스트박스, `ObservableCollection`→DataGrid. - `[Category("말머리")]` 같은 것끼리 화면에서 그룹으로 묶임 (없으면 말머리 없는 그룹). - `[DisplayName("이름")]` 생략 시 프로퍼티 이름 그대로 표시. - `[Browsable(false)]`면 화면에서 숨김. - 저장 파일: `D:\UbisamConfig\Settings\{클래스이름}.json` — 어느 탭에 붙여도 클래스가 같으면 같은 파일 공유. ### 1-2. 직접 만든 View + 수동 등록 View를 직접 그리고 싶으면 `ISettingsService`를 주입받아 등록합니다. ```csharp public MyViewModel(ISettingsService settingsService) { settings = settingsService.RegisterSettings("MyKey", new MySettingsClass()); } // settingsService.Save("MyKey"); / settingsService.Reload("MyKey"); ``` ### 1-3. 저장/새로고침 버튼 (플랫폼 자동 지원) 모든 설정 화면 우상단에 파란색 저장/새로고침 버튼이 동일하게 표시됩니다. 직접 만든 화면에서도 `SettingsToolbar` 컴포넌트를 붙이면 됩니다 — `DataContext`를 상속받아 호스트 ViewModel의 `RefreshCommand`/`SaveCommand`(`[RelayCommand] private void Refresh()/Save()`)에 바인딩됩니다. ```xml ``` 저장/새로고침 성공·실패 시 Success/Error 토스트가 자동으로 뜨도록 구현부에서 `MessageUtil.ShowSavedToast()` / `MessageUtil.ShowErrorToast(...)`를 호출하는 패턴을 따르세요 (플랫폼이 등록한 기본 4개 설정 화면이 이미 이 패턴입니다). --- ## 2. 로깅 — `UbisamBase.Core.Logging` ```csharp private readonly Logger log = new Logger("MyFeature"); // DI 없이 어디서든 생성 가능 log.V("verbose"); log.D("debug"); log.I("info"); log.W("warning"); log.E("error"); log.E("error", ex); ``` - 호출 즉시 `D:\UbisamConfig\Log\yyyyMMdd.log`에 기록 + 설정 > 로그 탭(`LogService.Current.RecentEntries`)에 실시간 반영. - **버튼 클릭 자동 로깅**: `ButtonClickLogger.Install()`이 부팅 시 한 번 걸려 있어서 (`EventManager.RegisterClassHandler(typeof(ButtonBase), ButtonBase.ClickEvent, ...)`), 플랫폼 전체(Core + 모든 소비 프로그램)의 모든 `Button`/`ToggleButton` 클릭이 코드 한 줄 없이 `"UI"` 태그로 자동 기록됩니다. 버튼마다 로그 코드를 넣을 필요가 없습니다. - 로그 뷰어: DataGrid, 레벨 필터(ALL = 전부, 특정 레벨 = 정확히 일치), 검색(LIKE, 부분 포함), 우클릭 복사. --- ## 3. 대화상자 / 토스트 — `UbisamBase.Core.Messaging` ```csharp // 대화상자 — OS MessageBox 대신 테마 적용된 MessageDialog가 자동으로 뜬다 MessageUtil.ShowInfo("알림", "저장되었습니다."); MessageUtil.ShowError("오류", "저장하지 못했습니다."); bool yes = MessageUtil.ShowYesNo("확인", "계속하시겠습니까?"); // 토스트 — 3초 후 자동 소멸, 화면 끝에서 슬라이드 인/아웃 MessageUtil.ShowToast("정보 메시지"); MessageUtil.ShowSuccessToast("저장되었습니다."); MessageUtil.ShowWarningToast("값이 허용 범위를 벗어났습니다."); MessageUtil.ShowErrorToast("장비 통신이 끊어졌습니다."); ``` 토스트 표시 위치(좌상단/좌하단/우상단/우하단)는 설정 > UI Setting에서 사용자가 바꿀 수 있습니다 (`UiSettingsService.ToastPosition`). --- ## 4. 차트 — `UbisamBase.Core.Charting.ChartTheme` (LiveCharts2) ```csharp public ISeries[] TrendSeries { get; private set; } public Axis[] TrendXAxes { get; private set; } public Axis[] TrendYAxes { get; private set; } public MyViewModel() { Rebuild(); ChartTheme.ThemeChanged += Rebuild; // 뷰가 사라질 때 -= 로 해제할 것 } private void Rebuild() { TrendSeries = new ISeries[] { ChartTheme.ReferenceLine(baseline, "기준"), ChartTheme.Line(measured, "측정") }; TrendXAxes = new[] { ChartTheme.XAxis(v => $"{v}s") }; TrendYAxes = new[] { ChartTheme.YAxis("0", min: 0, max: 100) }; OnPropertyChanged(nameof(TrendSeries)); // 등등 } ``` ```xml ``` - **`Background="Transparent"`를 꼭 지정** — 기본값은 흰 배경이라 다크 테마에서 튐. - **툴팁·범례는 따로 입혀야 한다** — 지정하지 않으면 LiveCharts 기본(밝은 바탕 + 검은 글자)이 나와 다크 테마에서 튄다. 차트를 만든 뒤와 테마가 바뀐 뒤에 `ChartTheme.Apply(chart)`를 한 번 부른다 (`CartesianChart` / `PieChart` 둘 다 있음). ### 4-1. 차트 종류별 — 무엇을 언제 쓰나 | 만들기 | 생김새 | 쓰는 자리 | | --- | --- | --- | | `Line(values, name)` | accent 선 2px, 보간 없음, 면적 12% | 단일 측정값 추이(기본) | | `Line(values, name, i)` | 팔레트 i번 선, 면적 없음 | 여러 계열을 한 그래프에 겹칠 때 | | `ReferenceLine(values, name)` | 회색 파선, hover 없음 | 기준·목표·규격 상하한. **측정선보다 먼저** 넣어야 아래에 깔린다 | | `SmoothLine(values, name, i)` | 곡선 보간(0.65) | 추세만 보여줄 때. **공정 측정값에는 쓰지 말 것** — 없던 중간값을 지어낸다 | | `Area(values, name, i)` | 선 + 채움 24% | 누적량·점유량처럼 "쌓인 양" | | `StepLine(values, name, i)` | 계단선 | ON/OFF·단계값처럼 다음 값까지 유지되는 신호 | | `Column(values, name[, i])` | 각진 세로 막대, 채움 85% | 항목별 비교(수량·횟수) | | `StackedColumn(values, name, i)` | 누적 세로 막대 | 전체 대비 구성비를 시간축으로 | | `Bar(values, name[, i])` | 가로 막대 | 항목 이름이 길어 세로 막대 라벨이 겹칠 때(설비명·불량 유형) | | `Pie(values[, names])` | 원형, 조각 사이 판 색 경계 | 구성비. 조각 6개 이하일 때만 | | `Donut(values[, names])` | 가운데 빈 원형 | 구성비 + 가운데에 총계 표시 | | `Gauge(percent)` | 0~100 도넛 게이지 | 가동률·달성률 한 값 | | `Scatter(points, name, i)` | 점 9px, 판 색 테두리 | 두 값의 상관 | - **색은 직접 고르지 않는다.** 단일 계열은 accent 하나, 여러 계열은 `seriesIndex`를 0,1,2…로 넘기면 `Ubisam.Brush.Chart.Series1~6` 팔레트를 순서대로 쓴다(라이트/다크 각각 따로 정의돼 있다). 7개째부터는 색이 처음으로 돌아가므로, 계열이 그보다 많으면 차트를 나누는 편이 낫다. - 팔레트 색이 필요하면 `ChartTheme.SeriesColor(i)`로 꺼내 쓴다(범례 표식 등). - 소비 프로젝트에서 차트를 직접 쓰려면 `LiveChartsCore`, `LiveChartsCore.SkiaSharpView`, `LiveChartsCore.SkiaSharpView.WPF`, `SkiaSharp` — 4개를 `$(UbisamPlatformDir)`에서 HintPath로 참조 추가해야 함(TestApp.csproj 예시 참고). --- ## 5. 화면 잠금 — `UbisamBase.Core.Locking` - 하단 네비 "화면 잠금" → `LockConfirmDialog`가 뜨고, **거기서 입력한 아이디/비밀번호가 그 순간 해제 키가 됨**(고정 계정 저장 방식 아님). `LockService.Current.Validator`를 그 자리에서 교체. - `LockOverlay`가 화면 전체를 불투명하게 덮음(Esc로 못 나감), 3회 실패 시 입력 잠김 → 구석의 "강제 잠금 해제" 버튼만 예외(`LockService.Current.ForceUnlock()`, 인증 없음). - 코드에서 직접 잠그려면: `LockService.Current.Lock()` (주의: 이 경우 `Validator`를 미리 설정해두지 않으면 아무도 못 풂 — 보통은 `LockConfirmDialog.ShowAndLock()`을 통해서만 잠금). - 잠글 때 입력값은 평문으로 `D:\UbisamConfig\ScreenSaver\last-lock.json`에 기록됨(요청 사양). --- ## 6. 스크린 세이버 — `UbisamBase.Core.ScreenSaving.ScreenSaverService` 화면 잠금과 달리 **인증 없이** 코드에서 자유롭게 켜고 끕니다 — 특정 작업 중 사용자 조작을 막을 때 사용. ```csharp ScreenSaverService.Current.Show("작업을 진행 중입니다..."); // 즉시 켜기 ScreenSaverService.Current.Hide(); // 즉시 끄기 // 시간을 넘기면, 그 시간이 지나도 서비스가 스스로 끄지 않고 이벤트만 올린다 — // 실제로 끌지는 구독한 쪽이 결정한다(항상 자유롭게 켜고 끌 수 있다는 원칙 유지). ScreenSaverService.Current.TimerElapsed += OnElapsed; ScreenSaverService.Current.Show("잠시만요...", seconds: 5); void OnElapsed() { ScreenSaverService.Current.TimerElapsed -= OnElapsed; ScreenSaverService.Current.Hide(); } ``` 반투명 스크림 + 화면 가운데 accent색 큰 글자로 `Show()`에 넘긴 문구가 표시됩니다. --- ## 7. 이벤트 버스 — `UbisamBase.Core.Messaging.IEventBus` 문자열 subject 기반 Pub/Sub. **`AddUbisamBaseCore`에서 이미 DI 싱글턴으로 등록되어 있음.** ```csharp public MyViewModel(IEventBus bus) { subscription = bus.Subscribe("Order.Completed", OnOrderCompleted); } private void OnOrderCompleted(EventMessage msg) { // msg.Subject, msg.Param, msg.From } // 다른 곳에서: bus.Publish(new EventMessage("Order.Completed", orderId, from: "OrderService")); // 화면이 사라질 때 subscription.Dispose(); ``` --- ## 8. 타임아웃 관리자 — `UbisamBase.Core.Utils.TimeOutManager` 문자열 키 기반 타이머 모음. **DI 싱글턴으로 등록되어 있음.** ```csharp public MyViewModel(TimeOutManager timers) { _timers = timers; } _timers["Ch1"].Start(limitMs: 5000); if (_timers["Ch1"].IsTimeout()) { /* 5초 초과 */ } long ms = _timers["Ch1"].End(); _timers.Remove("Ch1"); ``` 키는 미리 정의할 필요 없음(인덱서 접근 시 없으면 자동 생성). Start/End 시 `"TimeUtil"` 태그로 자동 로그가 남습니다. --- ## 8-1. 발표자 모드 — `UbisamBase.Core.Shell.PlatformChrome` 플랫폼이 그리는 껍데기(상단 브랜딩 바 · 네비게이션 탭 바 · 서브탭 줄)를 한 번에 숨긴다. 발표 자료를 띄울 때처럼 모듈 화면만 꽉 차게 보여주고 싶을 때 쓴다. ```csharp using UbisamBase.Core.Shell; PlatformChrome.IsVisible = false; // 플랫폼 껍데기 전부 숨김 (발표자 모드) PlatformChrome.IsVisible = true; // 원래대로 PlatformChrome.Toggle(); // 뒤집기 — 버튼/단축키에 걸어 쓰기 좋다 ``` - 기본값은 `true`. **저장하지 않는다** — 앱을 다시 켜면 항상 보이는 상태로 시작한다. - 숨기면 종료 · 화면 잠금 · 로그 · 캡처 버튼도 같이 사라진다. **다시 켤 방법(모듈 화면의 버튼이나 키 입력)을 반드시 모듈 쪽에 만들어 둬야 한다.** - 숨기면 **컨텐츠 여백(26,22)도 0이 된다** — 껍데기를 걷었는데 액자처럼 테두리가 남지 않는다. - 토스트 메시지, 화면 잠금, 스크린 세이버 오버레이는 이 스위치와 무관하게 그대로 뜬다. - 바뀔 때마다 `VisibleChanged` 이벤트가 온다. 설정의 "상단 바 접기"나 다른 모듈이 바꾼 경우에도 오므로, 발표 모드를 나갈 때 타이머를 멈추거나 띄워둔 창을 닫는 정리는 여기서 한다. ```csharp PlatformChrome.VisibleChanged += OnChromeChanged; Unloaded += (_, _) => PlatformChrome.VisibleChanged -= OnChromeChanged; // 정적 이벤트라 해제 필수 private void OnChromeChanged(object? sender, bool visible) { if (visible) { /* 발표 모드 종료 정리 — 타이머 정지, 발표자 창 닫기 등 */ } } ``` ## 8-2. 보조 모니터 — `UbisamBase.Core.Shell.Displays` 발표자 보기처럼 "본 창이 없는 쪽 모니터"에 창을 띄울 때 쓴다. 모듈이 `System.Windows.Forms`의 `Screen`을 직접 읽으면 실제 픽셀이 나와서, 배율(DPI)이 걸린 환경에서 DIP 환산을 빠뜨리면 창이 엉뚱한 자리에 뜬다 — 그 환산까지 여기서 처리한다. ```csharp var area = Displays.SecondaryWorkArea(Window.GetWindow(this)); // 모니터가 하나면 null if (area is Rect r) { var win = new PresentWindow { WindowStartupLocation = WindowStartupLocation.Manual }; win.Left = r.Left; win.Top = r.Top; win.Width = r.Width; win.Height = r.Height; win.Show(); } ``` - `Displays.HasSecondary` — 모니터가 두 대 이상인지. - `Displays.WorkArea(owner)` — owner 창이 있는 모니터의 작업 영역(DIP). - 값은 전부 **작업표시줄을 뺀 작업 영역**이고 WPF 좌표(DIP)다. 모니터가 셋 이상이면 owner가 없는 것 중 첫 번째(주 모니터 우선)를 돌려준다. ## 8-3. 닫기 확인 — `UbisamBase.Core.Modules.ICloseGuard` 저장하지 않은 변경이 있을 때 창이 그냥 닫히는 것을 막는 표준 통로. 모듈 View나 그 ViewModel이 구현하면 셸이 닫기 전에 물어본다. **셸 창의 `Closing`에 직접 붙지 말 것** — 모듈이 여럿이면 서로 Cancel/Close를 걸어 충돌한다. ```csharp public partial class DeckViewModel : ObservableObject, ICloseGuard { public async Task CanCloseAsync() { if (!IsDirty) return true; return await AskUserAsync("저장하지 않은 변경이 있습니다. 닫을까요?"); } } ``` - **한 번이라도 연 탭**만 물어본다(아직 안 연 탭은 화면을 만들지 않는다). - 하나라도 false를 돌려주면 거기서 멈추고 창은 닫히지 않는다(뒤의 화면에는 묻지 않는다). - 하단 "종료" 버튼도 이 경로를 탄다. 반대로 `Application.Current.Shutdown()`을 직접 부르면 이 확인을 건너뛴다 — 모듈에서는 쓰지 말 것. - UI 설정의 "상단 바 접기"는 사용자가 저장해 두는 별개 취향이다. 둘 중 하나라도 숨기라고 하면 상단 바는 숨는다. ## 9. 테마 · UI 컨벤션 - `Ubisam.Brush.*`, `Ubisam.Style.*`로 시작하는 리소스가 다크/라이트 테마 전체를 정의 (`src/UbisamBase.Core/Themes/Colors.Dark.xaml`, `Colors.Light.xaml`, `Controls.xaml`). - `TextBox`/`ComboBox`/`CheckBox`/`Button`/`ToggleButton`/`DataGrid`는 전부 **암시적 스타일** (`x:Key` 없음) — 그냥 `