데스크톱 T3 잔여 완성 + 스타일 폴리시: 도메인 라우팅(WorkspaceRoutingRules 자동 워크스페이스 전환), 탭 아카이브(TabArchive 영속·팔레트 복구), 페이지 액션(흑백/세피아 필터), 노트 패널(NotesStore+플라이아웃), 컴팩트 모드(크롬 숨김+엣지 호버). 스타일: 한국어 폰트 폴백, 암시 폰트 통일, 다크 툴팁, 텍스트 대비 상향. UpdateNavState dispose 가드. 164/164 GREEN + 실창 스크린샷 검증

This commit is contained in:
Yun Chan 2026-08-16 08:46:54 +09:00
parent 04ff0ff93d
commit f380518919
12 changed files with 594 additions and 24 deletions

View file

@ -48,8 +48,11 @@ public sealed class CommandPalette
public void Execute(PaletteCommand command) => command.Execute();
}
/// <summary>사이트별 부스트 설정(T2-7, Zen/Arc Boosts 패턴).</summary>
public sealed record BoostSettings(bool ForceDark, string CustomCss);
/// <summary>페이지 액션 필터(T3-6, Vivaldi Page Actions — Boost 통합).</summary>
public enum PageFilter { None, Grayscale, Sepia }
/// <summary>사이트별 부스트 설정(T2-7, Zen/Arc Boosts 패턴) + 페이지 액션 필터(T3-6).</summary>
public sealed record BoostSettings(bool ForceDark, string CustomCss, PageFilter Filter = PageFilter.None);
/// <summary>도메인별 부스트 영속 저장.</summary>
public sealed class BoostStore
@ -77,11 +80,11 @@ public sealed class BoostStore
return host != null && _map.TryGetValue(host, out var v) ? v : new BoostSettings(false, "");
}
public void Set(string? url, bool forceDark, string css)
public void Set(string? url, bool forceDark, string css, PageFilter filter = PageFilter.None)
{
var host = Normalize(url);
if (host == null) return;
_map[host] = new BoostSettings(forceDark, css);
_map[host] = new BoostSettings(forceDark, css, filter);
try
{
Directory.CreateDirectory(Path.GetDirectoryName(_path)!);
@ -110,7 +113,7 @@ public static class BoostCssBuilder
{
public static string BuildScript(string? url, BoostSettings settings)
{
if (!settings.ForceDark && string.IsNullOrWhiteSpace(settings.CustomCss)) return "";
if (!settings.ForceDark && settings.Filter == PageFilter.None && string.IsNullOrWhiteSpace(settings.CustomCss)) return "";
var css = "";
if (settings.ForceDark)
{
@ -119,6 +122,14 @@ html { filter: invert(1) hue-rotate(180deg); background: #fff; }
img, video, picture, canvas, svg, iframe, embed, object { filter: invert(1) hue-rotate(180deg); }
""";
}
else if (settings.Filter == PageFilter.Grayscale) // T3-6 페이지 액션
{
css += "html { filter: grayscale(1); }";
}
else if (settings.Filter == PageFilter.Sepia)
{
css += "html { filter: sepia(0.7); }";
}
if (!string.IsNullOrWhiteSpace(settings.CustomCss)) css += settings.CustomCss + "\n";
var js = $$"""
(function(){ try { var s = document.createElement('style'); s.id = 'vd-boost'; s.textContent = {{JsonSerializer.Serialize(css)}}; (document.head || document.documentElement).appendChild(s); } catch(e){} })();

View file

@ -153,7 +153,7 @@
</DataTemplate>
</Window.Resources>
<Grid>
<Grid x:Name="MainGrid">
<Grid.RowDefinitions>
<RowDefinition Height="Auto"/>
<RowDefinition Height="Auto"/>
@ -459,7 +459,7 @@
<!-- ===== 상태 표시줄 ===== -->
<Border x:Name="StatusBar" Grid.Row="4" Background="{StaticResource PanelBg}"
BorderBrush="{StaticResource BorderSubtle}" BorderThickness="0,1,0,0" Padding="16,5">
<TextBlock Name="StatusText" Foreground="{StaticResource TextMuted}" FontSize="11"
<TextBlock Name="StatusText" Foreground="{StaticResource TextSecondary}" FontSize="11.5"
TextTrimming="CharacterEllipsis" Text="준비"/>
</Border>
@ -550,6 +550,26 @@
</Popup>
<!-- ===== 다운로드 플라이아웃 (T1-5) ===== -->
<!-- T3-12 노트 패널 -->
<Popup Name="NotesPopup" PlacementTarget="{Binding ElementName=AddressBox}"
Placement="Bottom" HorizontalOffset="-40" VerticalOffset="6"
StaysOpen="False" AllowsTransparency="True" PopupAnimation="Fade">
<Border Background="{StaticResource CardBg}" BorderBrush="{StaticResource BorderBrush}"
BorderThickness="1" CornerRadius="12" Padding="10" Width="420" MaxHeight="460">
<StackPanel>
<DockPanel Margin="6,2,6,8">
<TextBlock Text="노트" Foreground="{StaticResource TextPrimary}" FontWeight="SemiBold" VerticalAlignment="Center"/>
<StackPanel DockPanel.Dock="Right" Orientation="Horizontal" HorizontalAlignment="Right">
<Button Style="{StaticResource CardActionButton}" Content="현재 페이지 추가" Click="NotesAddPage_Click" Margin="0,0,6,0"/>
<Button Style="{StaticResource CardActionButton}" Content="삭제" Click="NotesDelete_Click"/>
</StackPanel>
</DockPanel>
<ListBox Name="NotesList" Background="Transparent" BorderThickness="0" Height="340"
Foreground="{StaticResource TextPrimary}" FontSize="12"/>
</StackPanel>
</Border>
</Popup>
<!-- T3-14 추적 방지 실드 팝업 -->
<Popup Name="ShieldPopup" PlacementTarget="{Binding ElementName=ShieldBtn}"
Placement="Bottom" HorizontalOffset="-10" VerticalOffset="6"

View file

@ -50,6 +50,15 @@ public partial class MainWindow : Window
// Phase C: 팔레트/부스트/워크스페이스/레이아웃
private readonly CommandPalette _palette = new();
private BoostStore _boostStore = null!;
private WorkspaceRoutingRules _routingRules = null!; // T3-1
private TabArchive _tabArchive = null!; // T3-2
internal TabArchive Archive => _tabArchive; // E2E 검증용
internal BoostStore BoostStore => _boostStore; // E2E 검증용
internal NotesStore Notes => _notes; // E2E 검증용
private NotesStore _notes = null!; // T3-12
private string? _currentWorkspaceName; // T3-1 라우팅 기준
private bool _compactMode; // T3-13
private bool _chromeRevealed; // T3-13 호버 복원 상태
private List<WorkspaceSnapshot> _workspaces = new();
private bool _verticalTabs;
private TabItem? _splitTab;
@ -101,6 +110,9 @@ public partial class MainWindow : Window
_resolver = new SearchEngineResolver(_config.SearchEngine);
_zoomStore = new SiteZoomStore(Path.Combine(AppDataDir, "sitezoom.json"));
_boostStore = new BoostStore(Path.Combine(AppDataDir, "boost.json"));
_routingRules = new WorkspaceRoutingRules(Path.Combine(AppDataDir, "routing.json")); // T3-1
_tabArchive = new TabArchive(Path.Combine(AppDataDir, "archive.json")); // T3-2
_notes = new NotesStore(Path.Combine(AppDataDir, "notes.json")); // T3-12
try
{
var wsPath = Path.Combine(AppDataDir, "workspaces.json");
@ -434,16 +446,22 @@ public partial class MainWindow : Window
private void OnTabRemoved(TabItem tab)
{
if (ReferenceEquals(_splitTab, tab)) ExitSplit();
_tabArchive.Add(tab.Title, tab.Url); // T3-2 닫힘 → 아카이브(영속, 500 상한)
_views?.RemoveView(tab);
}
private void UpdateNavState()
{
var cv = Cv;
Dispatcher.BeginInvoke(new Action(() =>
{
NavBack.IsEnabled = cv?.CanGoBack == true;
NavForward.IsEnabled = cv?.CanGoForward == true;
try
{
var cv = Cv; // dispose 이후 이벤트 경로 방어
NavBack.IsEnabled = cv?.CanGoBack == true;
NavForward.IsEnabled = cv?.CanGoForward == true;
}
catch (ObjectDisposedException) { }
catch (InvalidOperationException) { }
}));
}
@ -717,6 +735,16 @@ public partial class MainWindow : Window
// T1-4: URL/검색어 판별 + 기본 엔진·키워드 전환(g, yt, ...)은 리졸버가 담당
var intent = _resolver.Resolve(url);
if (intent == null) return;
// T3-1: 도메인 라우팅 규칙 — 대상 워크스페이스가 다르면 자동 전환 후 이동(Arc ATC 패턴)
var routeTo = _routingRules.Match(intent.Target);
if (routeTo != null && routeTo != _currentWorkspaceName &&
_workspaces.FirstOrDefault(w => w.Name == routeTo) is { } wsTarget)
{
SwitchWorkspace(wsTarget);
_currentWorkspaceName = routeTo;
Log($"라우팅 규칙 → 워크스페이스 '{routeTo}' 전환");
}
Cv?.Navigate(intent.Target);
}
@ -778,6 +806,8 @@ public partial class MainWindow : Window
else if (mods == (ModifierKeys.Control | ModifierKeys.Shift) && e.Key == Key.D) { ToggleBoostDark(); e.Handled = true; }
else if (mods == (ModifierKeys.Control | ModifierKeys.Shift) && e.Key == Key.S) { _ = TakeScreenshot(); e.Handled = true; }
else if (mods == (ModifierKeys.Control | ModifierKeys.Shift) && e.Key == Key.N) { OpenInPrivateTab(); e.Handled = true; }
else if (mods == (ModifierKeys.Control | ModifierKeys.Shift) && e.Key == Key.M) { ToggleCompactMode(); e.Handled = true; } // T3-13
else if (mods == (ModifierKeys.Control | ModifierKeys.Alt) && e.Key == Key.N) { ToggleNotes(); e.Handled = true; } // T3-12
else if (mods == (ModifierKeys.Alt | ModifierKeys.Control) && e.Key == Key.V) { ToggleVerticalTabs(); e.Handled = true; }
else if (mods == (ModifierKeys.Alt | ModifierKeys.Control) && e.Key == Key.H) { ToggleSplit(); e.Handled = true; }
else if (mods == ModifierKeys.Control && e.Key == Key.U) { ViewSource(); e.Handled = true; }
@ -1243,6 +1273,20 @@ public partial class MainWindow : Window
_palette.Register("미디어 재생목록", "playlist play media 음악 영상", () => { PlaylistPopup.IsOpen = true; });
_palette.Register("재생목록 재생", "playlist play start", OpenPlayer);
_palette.Register("기기 연결 (QR 페어링)", "remote pair qr mobile phone", ShowPairing);
// ---- T3 잔여(데스크톱) ----
_palette.Register("이 사이트 흑백", "grayscale filter page action 흑백", () => ApplyPageFilter(PageFilter.Grayscale)); // T3-6
_palette.Register("이 사이트 세피아", "sepia filter page action 세피아", () => ApplyPageFilter(PageFilter.Sepia));
_palette.Register("페이지 액션 끄기", "filter off reset", () => ApplyPageFilter(PageFilter.None));
_palette.Register("노트 패널", "notes note 메모", ToggleNotes); // T3-12
_palette.Register("노트: 현재 페이지 추가", "note add", () => NotesAddPage_Click(this, new RoutedEventArgs()));
_palette.Register("컴팩트 모드 토글", "compact chrome hide ui", ToggleCompactMode); // T3-13
_palette.RegisterDynamic(() => _workspaces // T3-1: 사이트→워크스페이스 규칙
.Select(w => new PaletteCommand($"라우팅 규칙: 이 사이트 → '{w.Name}'", "routing rule workspace",
() => AddRoutingRuleForCurrentSite(w.Name))).ToList());
_palette.RegisterDynamic(() => _tabArchive.Search("") // T3-2: 아카이브 복구
.Take(8)
.Select(a => new PaletteCommand($"아카이브에서 열기: {a.Title}", $"archive reopen {a.Url}",
() => { var t = _tabs.NewTab(); Cv2Navigate(t, a.Url); })).ToList());
_palette.Register("원격 접근 켜기/끄기", "remote server toggle lan", async () => await ToggleRemoteAccess());
_palette.RegisterSeparator();
_palette.RegisterDynamic(() => _tabs.Tabs
@ -1304,6 +1348,112 @@ public partial class MainWindow : Window
Log(!cur.ForceDark ? "이 사이트 강제 다크 켜짐" : "이 사이트 강제 다크 꺼짐");
}
// ---- T3-6 페이지 액션(흑백/세피아 — Boost 통합) ----
internal void ApplyPageFilter(PageFilter filter)
{
var url = Cv?.Source;
if (string.IsNullOrEmpty(url)) { Log("페이지가 없습니다"); return; }
var cur = _boostStore.Get(url);
_boostStore.Set(url, cur.ForceDark, cur.CustomCss, filter);
_views?.ApplyBoostNow(_tabs.ActiveTab!);
Log(filter switch
{
PageFilter.Grayscale => "이 사이트 흑백 적용",
PageFilter.Sepia => "이 사이트 세피아 적용",
_ => "페이지 액션 해제",
});
}
// ---- T3-1 도메인 라우팅 규칙 ----
private void AddRoutingRuleForCurrentSite(string workspaceName)
{
var url = Cv?.Source;
if (string.IsNullOrWhiteSpace(url) || !Uri.TryCreate(url, UriKind.Absolute, out var u))
{ Log("페이지가 없습니다"); return; }
var host = u.Host;
if (host.StartsWith("www.", StringComparison.OrdinalIgnoreCase)) host = host[4..];
_routingRules.Add(host, workspaceName);
Log($"라우팅 규칙 추가: {host} → '{workspaceName}' (다음 이동부터 자동 전환)");
}
// ---- T3-2 아카이브 복구 보조 ----
private static void Cv2Navigate(TabItem tab, string url)
{
try { tab.View?.CoreWebView2?.Navigate(url); } catch { }
}
// ---- T3-12 노트 패널 ----
internal void ToggleNotes()
{
RefreshNotesList();
NotesPopup.IsOpen = true;
}
private void RefreshNotesList()
{
NotesList.ItemsSource = _notes.List()
.Select(n => $"[{n.CreatedAt:MM-dd HH:mm}] {n.Title} — {n.Body}")
.ToList();
NotesList.DisplayMemberPath = null;
}
internal void NotesAddPage_Click(object sender, RoutedEventArgs e)
{
var url = Cv?.Source ?? "";
var title = _tabs.ActiveTab?.Title ?? url;
_notes.Add(title, url);
Log("노트에 페이지 추가됨");
RefreshNotesList();
}
private void NotesDelete_Click(object sender, RoutedEventArgs e)
{
var idx = NotesList.SelectedIndex;
var list = _notes.List();
if (idx >= 0 && idx < list.Count)
{
_notes.Delete(list[idx].Id);
RefreshNotesList();
}
}
// ---- T3-13 컴팩트 모드(크롬 숨김 + 상단 엣지 호버 복원) ----
internal void ToggleCompactMode()
{
_compactMode = !_compactMode;
_chromeRevealed = false;
ApplyChromeVisibility();
Log(_compactMode ? "컴팩트 모드: 크롤 숨김 (상단 가장자리에 마우스를 올리면 복원)" : "컴팩트 모드 해제");
}
private void ApplyChromeVisibility()
{
var chromeRows = new[] { 0, 1, 2 }; // 툴바/북마크바/탭스트립
foreach (var i in chromeRows)
MainGrid.RowDefinitions[i].Height = (_compactMode && !_chromeRevealed) ? new GridLength(0) : GridLength.Auto;
if (!_compactMode) _chromeRevealed = false;
}
protected override void OnPreviewMouseMove(System.Windows.Input.MouseEventArgs e)
{
if (_compactMode)
{
var p = e.GetPosition(this);
var wantReveal = p.Y <= 4; // 상단 엣지 4px
if (wantReveal != _chromeRevealed)
{
_chromeRevealed = wantReveal;
ApplyChromeVisibility();
}
else if (_chromeRevealed && p.Y > 120) // 크롬 영역 벗어나면 다시 숨김
{
_chromeRevealed = false;
ApplyChromeVisibility();
}
}
base.OnPreviewMouseMove(e);
}
// ---- T2-9 스크린샷 ----
private async Task TakeScreenshot()
{
@ -1490,7 +1640,7 @@ public partial class MainWindow : Window
if (cv == null) return;
var tiles = _bookmarks.Take(18).Select(b =>
$"<a href='{System.Net.WebUtility.HtmlEncode(b.Url)}' style='display:block;text-decoration:none;background:#262736;color:#e6e6ec;border-radius:12px;padding:18px 12px;text-align:center;font-size:12.5px;min-width:110px' onmouseover='this.style.background=#2f3042' onmouseout='this.style.background=#262736'><div style='font-size:26px;margin-bottom:8px'>&#127760;</div><div style='overflow:hidden;text-overflow:ellipsis;white-space:nowrap'>{System.Net.WebUtility.HtmlEncode(b.Title)}</div></a>").ToList();
var dial = "<!DOCTYPE html><html><head><meta charset='utf-8'><style>body{background:#14151d;color:#e6e6ec;font-family:'Segoe UI',sans-serif;margin:0;padding:48px 24px}h1{font-size:20px;text-align:center;margin-bottom:28px;font-weight:600}.grid{display:flex;flex-wrap:wrap;gap:14px;justify-content:center;max-width:760px;margin:0 auto}.hint{text-align:center;color:#8a8aa0;font-size:12px;margin-top:36px}a{color:inherit}</style></head><body><h1>새 탭</h1><div class='grid'>" + string.Join("", tiles) + $"</div><div class='hint'>주소창에 검색어나 URL 을 입력하세요 · 즐겨찾기 {_bookmarks.Count}개 중 {tiles.Count}개 표시</div></body></html>";
var dial = "<!DOCTYPE html><html><head><meta charset='utf-8'><style>body{background:#14151d;color:#e6e6ec;font-family:'Segoe UI Variable Text','Segoe UI','Malgun Gothic',sans-serif;margin:0;padding:48px 24px}h1{font-size:20px;text-align:center;margin-bottom:28px;font-weight:600}.grid{display:flex;flex-wrap:wrap;gap:14px;justify-content:center;max-width:760px;margin:0 auto}.hint{text-align:center;color:#9aa0b4;font-size:12.5px;margin-top:36px}a{color:inherit}</style></head><body><h1>새 탭</h1><div class='grid'>" + string.Join("", tiles) + $"</div><div class='hint'>주소창에 검색어나 URL 을 입력하세요 · 즐겨찾기 {_bookmarks.Count}개 중 {tiles.Count}개 표시</div></body></html>";
cv.NavigateToString(dial);
}
@ -1575,6 +1725,7 @@ public partial class MainWindow : Window
{
ExitSplit();
_tabs.SwitchWorkspace(ws);
_currentWorkspaceName = ws.Name; // T3-1 라우팅 기준
Log($"워크스페이스 '{ws.Name}' 전환 ({ws.Urls.Count} 탭)");
}

View file

@ -12,11 +12,11 @@
<SolidColorBrush x:Key="GlassBg" Color="#1A1A24"/>
<SolidColorBrush x:Key="CardBg" Color="#1C1C26"/>
<SolidColorBrush x:Key="CardBgHover" Color="#242430"/>
<SolidColorBrush x:Key="BorderBrush" Color="#2C2C3A"/>
<SolidColorBrush x:Key="BorderBrush" Color="#343442"/>
<SolidColorBrush x:Key="BorderSubtle" Color="#20202B"/>
<SolidColorBrush x:Key="TextPrimary" Color="#F2F2F6"/>
<SolidColorBrush x:Key="TextSecondary" Color="#9B9BAC"/>
<SolidColorBrush x:Key="TextMuted" Color="#62626E"/>
<SolidColorBrush x:Key="TextSecondary" Color="#A9B0C5"/>
<SolidColorBrush x:Key="TextMuted" Color="#7C8296"/>
<SolidColorBrush x:Key="TrackBrush" Color="#262631"/>
<SolidColorBrush x:Key="SuccessBrush" Color="#34D399"/>
<SolidColorBrush x:Key="ErrorBrush" Color="#F87171"/>

View file

@ -15,8 +15,8 @@
<SolidColorBrush x:Key="BorderBrush" Color="#D5D9E1"/>
<SolidColorBrush x:Key="BorderSubtle" Color="#E3E6EC"/>
<SolidColorBrush x:Key="TextPrimary" Color="#1B1C24"/>
<SolidColorBrush x:Key="TextSecondary" Color="#565A6A"/>
<SolidColorBrush x:Key="TextMuted" Color="#8A8E9C"/>
<SolidColorBrush x:Key="TextSecondary" Color="#4A4E5E"/>
<SolidColorBrush x:Key="TextMuted" Color="#767B8C"/>
<SolidColorBrush x:Key="TrackBrush" Color="#DBDFE6"/>
<SolidColorBrush x:Key="SuccessBrush" Color="#15A34A"/>
<SolidColorBrush x:Key="ErrorBrush" Color="#DC2626"/>

View file

@ -1,10 +1,23 @@
<ResourceDictionary xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml">
<!-- 폰트 (테마 무관) -->
<FontFamily x:Key="UiFont">Segoe UI Variable Text, Segoe UI</FontFamily>
<!-- 폰트 (테마 무관) — 한국어 폴백 포함(맑은 고딕) -->
<FontFamily x:Key="UiFont">Segoe UI Variable Text, Segoe UI, Malgun Gothic</FontFamily>
<FontFamily x:Key="IconFont">Segoe Fluent Icons</FontFamily>
<!-- 암시 스타일: 모든 텍스트가 UI 폰트로 통일(Foreground 는 상속 존중) -->
<Style TargetType="TextBlock">
<Setter Property="FontFamily" Value="{StaticResource UiFont}"/>
</Style>
<Style TargetType="ToolTip">
<Setter Property="FontFamily" Value="{StaticResource UiFont}"/>
<Setter Property="Foreground" Value="{StaticResource TextPrimary}"/>
<Setter Property="Background" Value="{StaticResource CardBg}"/>
<Setter Property="BorderBrush" Value="{StaticResource BorderBrush}"/>
<Setter Property="FontSize" Value="12"/>
<Setter Property="Padding" Value="10,6"/>
</Style>
<!-- ===== Primary 버튼 ===== -->
<Style x:Key="PrimaryButton" TargetType="Button">
<Setter Property="FontFamily" Value="{StaticResource UiFont}"/>