diff --git a/docs/releases/2.0.0_en.md b/docs/releases/2.0.0_en.md index 6520cd476..bbce029fe 100644 --- a/docs/releases/2.0.0_en.md +++ b/docs/releases/2.0.0_en.md @@ -9,6 +9,7 @@ - The main toolbar, properties panel, settings, modals, popups, pickers, and minimap now share one visual and behavioral baseline. - The first-run default preset and the default styles for keys, knobs, graphs, stats, counters, and notes have been rebuilt. - Replaced the app icon. + - The new key design was created with inspiration from [@DominoKorean](https://github.com/DominoKorean)'s [custom CSS skins](https://github.com/DominoKorean/DM-Note-skins). - Moved settings into the Side Panel - Sound, font, Custom CSS, and plugin settings are managed on the current screen without a separate manager window. @@ -54,6 +55,7 @@ - `Combined` triggers only while all bound keys are held. - Keyboard, mouse, and HID buttons (Windows) can be mixed within the same slot. - Add or remove keys and switch the trigger mode directly in the binding popup. + - [@superalan89](https://github.com/superalan89) suggested [multi-key binding support](https://github.com/DmNote-App/DmNote/issues/71). ๐ŸŽ‰ ### Color, Image, and Font @@ -148,6 +150,7 @@ - Added Intel Mac support [Issues 125](https://github.com/DmNote-App/DmNote/issues/125) - macOS builds are now universal binaries, so the same DMG runs on both Intel and Apple silicon Macs. + - [@ByrenYan1](https://github.com/ByrenYan1) suggested [Intel Mac support](https://github.com/DmNote-App/DmNote/issues/125). ๐ŸŽ‰ - Added support for high refresh rate displays on macOS [Issues 122](https://github.com/DmNote-App/DmNote/issues/122) - The overlay and editing screen update more smoothly on 120Hz and faster displays. @@ -173,6 +176,7 @@ - Image, sound, and font files are now quarantined in a per-session trash for 30 days instead of being deleted immediately - Fixed half-written state after a conflict or crash by making save file replacement, asset restore, and failure rollback atomic - Fixed custom images on knobs disappearing after a restart [Issues 91](https://github.com/DmNote-App/DmNote/issues/91) + - [@superalan89](https://github.com/superalan89) reported [custom images on knobs going missing](https://github.com/DmNote-App/DmNote/issues/91). ๐ŸŽ‰ - Fixed an issue where older undo/redo steps revived already-deleted sound, image, counter motion, and plugin references ### Panel, Popup, and Input @@ -193,6 +197,7 @@ - Lowered the minimum note corner radius to 0 and changed the default `Glow Size` to 10 - Fixed short notes disappearing abruptly or looking cut off at the display boundary - Reduced key event delivery latency by enabling low-latency transmission on the OBS WebSocket [Issues 72](https://github.com/DmNote-App/DmNote/issues/72) + - [@aigonanMTE](https://github.com/aigonanMTE) reported [key viewer stuttering in OBS mode](https://github.com/DmNote-App/DmNote/issues/72). ๐ŸŽ‰ ### Color, Image, Font, and CSS @@ -205,6 +210,7 @@ - Fixed plugin JavaScript failures being reported as success, and truncated error messages - Fixed the pressed state in the key viewer flickering every time a plugin updated its state [Issues 111](https://github.com/DmNote-App/DmNote/issues/111) + - [@hjg-06](https://github.com/hjg-06) contributed to [the pressed state flickering on plugin state updates](https://github.com/DmNote-App/DmNote/issues/111). ๐ŸŽ‰ - Fixed an exception in a plugin's `onMount` blanking the entire overlay - Fixed late writes from a failed or removed plugin overwriting the new run state - Restored compatibility for native `window` methods, async context, and existing global APIs used by plugins @@ -217,6 +223,7 @@ - Fixed the overlay on macOS not reaching the top of the screen and leaving empty space below it [Issues 121](https://github.com/DmNote-App/DmNote/issues/121) - `Snap to Edge` now measures from the screen edge instead of the work area, removing the gap left by the Dock and the taskbar - The track height reserved at the top is excluded from the window height when note effects are off + - [@AL-Pinecore](https://github.com/AL-Pinecore) contributed to [overlay placement on macOS](https://github.com/DmNote-App/DmNote/issues/121). ๐ŸŽ‰ - Added a quit item to the macOS app menu and stabilized Dock quit, restart, and helper process cleanup - macOS no longer raises the accessibility permission prompt on its own while the app is running - Fixed cancellations being reported as failures and the UI blocking by making file and preset dialogs async and parented to the calling window diff --git a/docs/releases/2.0.0_ko.md b/docs/releases/2.0.0_ko.md index 9cb62a4ac..0059af5a2 100644 --- a/docs/releases/2.0.0_ko.md +++ b/docs/releases/2.0.0_ko.md @@ -9,6 +9,7 @@ - ๋ฉ”์ธ ํˆด๋ฐ”, ์†์„ฑ ํŒจ๋„, ์„ค์ •, ๋ชจ๋‹ฌ, ํŒ์—…, ํ”ผ์ปค, ๋ฏธ๋‹ˆ๋งต์˜ ๋””์ž์ธ๊ณผ ๋™์ž‘์„ ํ•˜๋‚˜์˜ ๊ธฐ์ค€์œผ๋กœ ํ†ต์ผํ–ˆ์Šต๋‹ˆ๋‹ค. - ์ฒซ ์‹คํ–‰ ๊ธฐ๋ณธ ํ”„๋ฆฌ์…‹๊ณผ ํ‚ค, ๋…ธ๋ธŒ, ๊ทธ๋ž˜ํ”„, ํ†ต๊ณ„, ์นด์šดํ„ฐ, ๋…ธํŠธ์˜ ๊ธฐ๋ณธ ์Šคํƒ€์ผ์„ ์ƒˆ๋กœ ๊ตฌ์„ฑํ–ˆ์Šต๋‹ˆ๋‹ค. - ์•ฑ ์•„์ด์ฝ˜์„ ๊ต์ฒดํ–ˆ์Šต๋‹ˆ๋‹ค. + - ์‹ ๊ทœ ํ‚ค ๋””์ž์ธ์€ @DominoKorean ๋‹˜์˜ [์ปค์Šคํ…€ CSS ์Šคํ‚จ](https://github.com/DominoKorean/DM-Note-skins)์—์„œ ์˜๊ฐ์„ ๋ฐ›์•„ ๋งŒ๋“ค์—ˆ์Šต๋‹ˆ๋‹ค. - ์„ค์ •์„ ์‚ฌ์ด๋“œ ํŒจ๋„๋กœ ์ด๋™ - ์‚ฌ์šด๋“œ, ํฐํŠธ, ์ปค์Šคํ…€ CSS, ํ”Œ๋Ÿฌ๊ทธ์ธ ์„ค์ •์„ ๋ณ„๋„ ๊ด€๋ฆฌ ์ฐฝ ์—†์ด ํ˜„์žฌ ํ™”๋ฉด์—์„œ ๋ฐ”๋กœ ๊ด€๋ฆฌํ•  ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค. @@ -54,6 +55,7 @@ - `๋™์‹œ ์ž…๋ ฅ`์€ ๋“ฑ๋กํ•œ ํ‚ค๋ฅผ ๋ชจ๋‘ ๋ˆ„๋ฅด๊ณ  ์žˆ๋Š” ๋™์•ˆ์—๋งŒ ๋ฐ˜์‘ํ•ฉ๋‹ˆ๋‹ค. - ํ‚ค๋ณด๋“œ, ๋งˆ์šฐ์Šค, HID ๋ฒ„ํŠผ(Windows)์„ ํ•œ ์Šฌ๋กฏ ์•ˆ์—์„œ ์„ž์–ด ์“ธ ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค. - ๋งคํ•‘ ํŒ์—…์—์„œ ํ‚ค๋ฅผ ์ถ”๊ฐ€, ์ œ๊ฑฐํ•˜๊ณ  ๋ฐ˜์‘ ๋ฐฉ์‹์„ ๋ฐ”๋กœ ์„ ํƒํ•  ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค. + - @superalan89 ๋‹˜์ด [๋‹ค์ค‘ ํ‚ค ๋งคํ•‘ ์ง€์›](https://github.com/DmNote-App/DmNote/issues/71)์„ ์ œ์•ˆํ•ด์ฃผ์…จ์Šต๋‹ˆ๋‹ค. ๐ŸŽ‰ ### ์ƒ‰์ƒ, ์ด๋ฏธ์ง€ ๋ฐ ํฐํŠธ @@ -148,6 +150,7 @@ - ์ธํ…” ๋งฅ ์ง€์› [Issues 125](https://github.com/DmNote-App/DmNote/issues/125) - macOS ๋นŒ๋“œ๋ฅผ ์œ ๋‹ˆ๋ฒ„์„ค ๋ฐ”์ด๋„ˆ๋ฆฌ๋กœ ์ „ํ™˜ํ•ด ์ธํ…” ๋งฅ๊ณผ ์• ํ”Œ ์‹ค๋ฆฌ์ฝ˜ ๋งฅ์—์„œ ๊ฐ™์€ DMG๋ฅผ ์‚ฌ์šฉํ•ฉ๋‹ˆ๋‹ค. + - @ByrenYan1 ๋‹˜์ด [์ธํ…” ๋งฅ ์ง€์›](https://github.com/DmNote-App/DmNote/issues/125)์„ ์ œ์•ˆํ•ด์ฃผ์…จ์Šต๋‹ˆ๋‹ค. ๐ŸŽ‰ - macOS ๊ณ ์ฃผ์‚ฌ์œจ ๋””์Šคํ”Œ๋ ˆ์ด ์ง€์› [Issues 122](https://github.com/DmNote-App/DmNote/issues/122) - 120Hz ์ด์ƒ ๋””์Šคํ”Œ๋ ˆ์ด์—์„œ ์˜ค๋ฒ„๋ ˆ์ด์™€ ํŽธ์ง‘ ํ™”๋ฉด์ด ๋” ๋ถ€๋“œ๋Ÿฝ๊ฒŒ ๊ฐฑ์‹ ๋ฉ๋‹ˆ๋‹ค. @@ -173,6 +176,7 @@ - ์ด๋ฏธ์ง€, ์‚ฌ์šด๋“œ, ํฐํŠธ ํŒŒ์ผ์„ ์ฆ‰์‹œ ์‚ญ์ œํ•˜์ง€ ์•Š๊ณ  ์„ธ์…˜๋ณ„ ํœด์ง€ํ†ต์— 30์ผ ๊ฒฉ๋ฆฌํ•˜๋„๋ก ๋ณ€๊ฒฝ - ์ €์žฅ ํŒŒ์ผ ๊ต์ฒด, ์ž์‚ฐ ๋ณต์›, ์‹คํŒจ ๋กค๋ฐฑ์„ ์›์ž์ ์œผ๋กœ ์ฒ˜๋ฆฌํ•ด ์ถฉ๋Œ์ด๋‚˜ ํฌ๋ž˜์‹œ ๋’ค ์ผ๋ถ€ ์ƒํƒœ๋งŒ ๋‚จ๋˜ ๋ฌธ์ œ ์ˆ˜์ • - ๋…ธ๋ธŒ์— ์ ์šฉํ•œ ์ปค์Šคํ…€ ์ด๋ฏธ์ง€๊ฐ€ ์žฌ์‹คํ–‰ ํ›„ ์‚ฌ๋ผ์ง€๋˜ ๋ฌธ์ œ ์ˆ˜์ • [Issues 91](https://github.com/DmNote-App/DmNote/issues/91) + - @superalan89 ๋‹˜์ด [๋…ธ๋ธŒ ์ปค์Šคํ…€ ์ด๋ฏธ์ง€ ๋ˆ„๋ฝ ๋ฌธ์ œ](https://github.com/DmNote-App/DmNote/issues/91)๋ฅผ ์ œ๋ณดํ•ด์ฃผ์…จ์Šต๋‹ˆ๋‹ค. ๐ŸŽ‰ - ์‚ฌ์šด๋“œ, ์ด๋ฏธ์ง€, ์นด์šดํ„ฐ ๋ชจ์…˜, ํ”Œ๋Ÿฌ๊ทธ์ธ ๋ฐ์ดํ„ฐ๋ฅผ ์‚ญ์ œํ•œ ๋’ค ๊ณผ๊ฑฐ Undo/Redo๊ฐ€ ์ด๋ฏธ ์‚ญ์ œ๋œ ์ฐธ์กฐ๋ฅผ ๋ณต์›ํ•˜๋˜ ๋ฌธ์ œ ์ˆ˜์ • ### ํŒจ๋„, ํŒ์—… ๋ฐ ์ž…๋ ฅ @@ -193,6 +197,7 @@ - ๋…ธํŠธ ๋ผ์šด๋”ฉ ์ตœ์†Ÿ๊ฐ’์„ 0์œผ๋กœ ์กฐ์ •ํ•˜๊ณ  ๊ธฐ๋ณธ ๊ธ€๋กœ์šฐ ๋ฒ”์œ„๋ฅผ 10์œผ๋กœ ๋ณ€๊ฒฝ - ์งง์€ ๋…ธํŠธ๊ฐ€ ํ‘œ์‹œ ๊ฒฝ๊ณ„์—์„œ ๊ฐ‘์ž๊ธฐ ์‚ฌ๋ผ์ง€๊ฑฐ๋‚˜ ๊ธธ์ด๊ฐ€ ๋Š๊ฒจ ๋ณด์ด๋˜ ๋ฌธ์ œ ์ˆ˜์ • - OBS WebSocket์— ์ €์ง€์—ฐ ์ „์†ก ์˜ต์…˜์„ ์ ์šฉํ•ด ํ‚ค ์ด๋ฒคํŠธ ์ „๋‹ฌ ์ง€์—ฐ์„ ์ค„์ž„ [Issues 72](https://github.com/DmNote-App/DmNote/issues/72) + - @aigonanMTE ๋‹˜์ด [OBS ๋ชจ๋“œ ํ‚ค๋ทฐ์–ด ๋Š๊น€ ๋ฌธ์ œ](https://github.com/DmNote-App/DmNote/issues/72)๋ฅผ ์ œ๋ณดํ•ด์ฃผ์…จ์Šต๋‹ˆ๋‹ค. ๐ŸŽ‰ ### ์ƒ‰์ƒ, ์ด๋ฏธ์ง€, ํฐํŠธ ๋ฐ CSS @@ -205,6 +210,7 @@ - ํ”Œ๋Ÿฌ๊ทธ์ธ JavaScript ์‹คํ–‰ ์‹คํŒจ๋ฅผ ์„ฑ๊ณต์œผ๋กœ ํ‘œ์‹œํ•˜๊ฑฐ๋‚˜ ์˜ค๋ฅ˜ ์•ˆ๋‚ด๊ฐ€ ์ž˜๋ ค ๋ณด์ด๋˜ ๋ฌธ์ œ ์ˆ˜์ • - ํ”Œ๋Ÿฌ๊ทธ์ธ์ด ์ƒํƒœ๋ฅผ ๊ฐฑ์‹ ํ•  ๋•Œ๋งˆ๋‹ค ํ‚ค๋ทฐ์–ด์˜ ๋ˆŒ๋ฆผ ํ‘œ์‹œ๊ฐ€ ๊นœ๋นก์ด๋˜ ๋ฌธ์ œ ์ˆ˜์ • [Issues 111](https://github.com/DmNote-App/DmNote/issues/111) + - @hjg-06 ๋‹˜์ด [ํ”Œ๋Ÿฌ๊ทธ์ธ ์ƒํƒœ ๊ฐฑ์‹  ์‹œ ๋ˆŒ๋ฆผ ํ‘œ์‹œ ๊นœ๋นก์ž„](https://github.com/DmNote-App/DmNote/issues/111)์— ๊ธฐ์—ฌํ•ด์ฃผ์…จ์Šต๋‹ˆ๋‹ค. ๐ŸŽ‰ - ํ”Œ๋Ÿฌ๊ทธ์ธ `onMount` ์˜ˆ์™ธ๊ฐ€ ์˜ค๋ฒ„๋ ˆ์ด ์ „์ฒด๋ฅผ ๋น„์šฐ๋˜ ๋ฌธ์ œ ์ˆ˜์ • - ์‹คํŒจํ–ˆ๊ฑฐ๋‚˜ ์ œ๊ฑฐ๋œ ํ”Œ๋Ÿฌ๊ทธ์ธ์˜ ๋Šฆ์€ ์ €์žฅ์ด ์ƒˆ ์‹คํ–‰ ์ƒํƒœ๋ฅผ ๋ฎ์–ด์“ฐ๋˜ ๋ฌธ์ œ ์ˆ˜์ • - ํ”Œ๋Ÿฌ๊ทธ์ธ์ด ์‚ฌ์šฉํ•˜๋Š” `window`์˜ ๋„ค์ดํ‹ฐ๋ธŒ ๋ฉ”์„œ๋“œ, ๋น„๋™๊ธฐ ์ปจํ…์ŠคํŠธ, ๊ธฐ์กด ์ „์—ญ API ํ˜ธํ™˜์„ฑ ๋ณต๊ตฌ @@ -217,6 +223,7 @@ - macOS ์˜ค๋ฒ„๋ ˆ์ด๊ฐ€ ํ™”๋ฉด ์œ„์ชฝ์— ๋‹ฟ์ง€ ์•Š๊ณ  ๋นˆ ๊ณต๊ฐ„์ด ๋‚จ๋˜ ๋ฌธ์ œ ์ˆ˜์ • [Issues 121](https://github.com/DmNote-App/DmNote/issues/121) - `ํ™”๋ฉด ๋ชจ์„œ๋ฆฌ์— ๋งž์ถ”๊ธฐ` ๊ธฐ์ค€์„ ์ž‘์—… ์˜์—ญ์—์„œ ํ™”๋ฉด ๋์œผ๋กœ ์ •์ •ํ•ด ๋…๊ณผ ์ž‘์—… ํ‘œ์‹œ์ค„ ์˜์—ญ๋งŒํผ ๋–  ์žˆ๋˜ ์—ฌ๋ฐฑ ์ œ๊ฑฐ - ๋…ธํŠธ ํšจ๊ณผ๋ฅผ ๋„๋ฉด ์ƒ๋‹จ์— ์˜ˆ์•ฝ๋˜๋˜ ํŠธ๋ž™ ๋†’์ด๋ฅผ ์ฐฝ ๋†’์ด์—์„œ ํ•จ๊ป˜ ์ œ์™ธ + - @AL-Pinecore ๋‹˜์ด [macOS ์˜ค๋ฒ„๋ ˆ์ด ๋ฐฐ์น˜ ๋ฌธ์ œ](https://github.com/DmNote-App/DmNote/issues/121)์— ๊ธฐ์—ฌํ•ด์ฃผ์…จ์Šต๋‹ˆ๋‹ค. ๐ŸŽ‰ - macOS ์•ฑ ๋ฉ”๋‰ด์— ์ข…๋ฃŒ ํ•ญ๋ชฉ์„ ์ถ”๊ฐ€ํ•˜๊ณ  Dock ์ข…๋ฃŒ, ์žฌ์‹œ์ž‘, ํ—ฌํผ ํ”„๋กœ์„ธ์Šค ์ •๋ฆฌ ์•ˆ์ •ํ™” - macOS์—์„œ ์•ฑ ์‹คํ–‰ ์ค‘ ์ ‘๊ทผ์„ฑ ๊ถŒํ•œ ์š”์ฒญ์ฐฝ์ด ์ €์ ˆ๋กœ ๋œจ์ง€ ์•Š๋„๋ก ๋ณ€๊ฒฝ - ํŒŒ์ผ๊ณผ ํ”„๋ฆฌ์…‹ ์„ ํƒ์ฐฝ์„ ํ˜ธ์ถœํ•œ ์ฐฝ์— ๋ถ™๋Š” ๋น„๋™๊ธฐ ๋‹ค์ด์–ผ๋กœ๊ทธ๋กœ ํ†ต์ผํ•ด, ์ทจ์†Œ๋ฅผ ์‹คํŒจ๋กœ ์•Œ๋ฆฌ๊ฑฐ๋‚˜ UI๋ฅผ ๋ง‰๋˜ ๋ฌธ์ œ ์ˆ˜์ • diff --git a/src-tauri/src/commands/keys/keys.rs b/src-tauri/src/commands/keys/keys.rs index 01ea3fdf0..7c3b4836a 100644 --- a/src-tauri/src/commands/keys/keys.rs +++ b/src-tauri/src/commands/keys/keys.rs @@ -571,7 +571,8 @@ fn keys_reset_all_inner( &stat_positions, &app_data_dir, ); - let settings_diff = apply_patch_to_store(store, &settings_patch); + let settings_diff = apply_patch_to_store(store, &settings_patch) + .map_err(crate::services::settings::settings_patch_validation_error)?; Ok((settings_diff, cleared_tab_css_ids)) }, )?; diff --git a/src-tauri/src/commands/layout/settings.rs b/src-tauri/src/commands/layout/settings.rs index f88382859..914a09eda 100644 --- a/src-tauri/src/commands/layout/settings.rs +++ b/src-tauri/src/commands/layout/settings.rs @@ -36,9 +36,8 @@ pub async fn settings_update( state .store .commit_history_overlap_mutation_with_admission(admission, |store| { - Ok(crate::services::settings::apply_patch_to_store( - store, &patch, - )) + crate::services::settings::apply_patch_to_store(store, &patch) + .map_err(crate::services::settings::settings_patch_validation_error) })?; if let Some(previous) = previous.as_ref() { state.resync_global_css_watcher(previous, &state.store.snapshot()); diff --git a/src-tauri/src/commands/preset/load.rs b/src-tauri/src/commands/preset/load.rs index 3d442d414..615ddbaa8 100644 --- a/src-tauri/src/commands/preset/load.rs +++ b/src-tauri/src/commands/preset/load.rs @@ -520,7 +520,8 @@ fn preset_load_from_path( } rekey_full_preset_elements(store); crate::state::migration::clear_dangling_group_ids(store); - let diff = apply_patch_to_store(store, &settings_patch); + let diff = apply_patch_to_store(store, &settings_patch) + .map_err(crate::services::settings::settings_patch_validation_error)?; Ok(( diff, previous_tab_css_overrides, @@ -824,7 +825,9 @@ fn preset_load_tab_from_path( ..SettingsPatchInput::default() }, ) - }); + }) + .transpose() + .map_err(crate::services::settings::settings_patch_validation_error)?; crate::state::migration::clear_dangling_group_ids(store); Ok(( settings_diff, @@ -1791,6 +1794,7 @@ mod tests { }]; let mut store = AppStoreData { custom_css_history: history.clone(), + ui_theme: crate::models::UiTheme::Light, ..AppStoreData::default() }; let mut preset = PresetFile { @@ -1802,6 +1806,8 @@ mod tests { ..PresetFile::default() }; let resolved = resolve_full_preset_settings(&mut preset, &store); + let preset_wire = serde_json::to_value(&preset).expect("preset ์ง๋ ฌํ™”"); + assert!(preset_wire.get("uiTheme").is_none()); let patch = SettingsPatchInput { use_custom_css: Some(resolved.use_custom_css), custom_css: Some(CustomCssPatch { @@ -1811,9 +1817,10 @@ mod tests { ..SettingsPatchInput::default() }; - apply_patch_to_store(&mut store, &patch); + apply_patch_to_store(&mut store, &patch).expect("preset ์„ค์ • ์ ์šฉ"); assert_eq!(store.custom_css_history, history); + assert_eq!(store.ui_theme, crate::models::UiTheme::Light); } #[test] diff --git a/src-tauri/src/main.rs b/src-tauri/src/main.rs index 15a15aa3d..6889f3218 100644 --- a/src-tauri/src/main.rs +++ b/src-tauri/src/main.rs @@ -37,7 +37,6 @@ const INTERACTION_BENCHMARK_ENV: &str = "DMN_INTERACTION_WEBVIEW_BENCHMARK"; // ๋ฉ”์ธ ์ฐฝ ๋…ผ๋ฆฌ ํฌ๊ธฐ - ๊ณ ์ • ํฌ๊ธฐ์ด๊ณ , ๋ถ„๋ฆฌ ํŒจ๋„์ด ์ด ์‚ฌ๊ฐํ˜•์„ ๊ธฐ์ค€์œผ๋กœ ์˜†์— ๋ถ™๋Š”๋‹ค const MAIN_WINDOW_WIDTH: f64 = 902.0; const MAIN_WINDOW_HEIGHT: f64 = 488.0; - fn is_interaction_benchmark_mode() -> bool { std::env::var_os(INTERACTION_BENCHMARK_ENV).is_some() } @@ -595,6 +594,11 @@ fn apply_main_window_configuration( log::debug!("failed to hide main window before configuration: {err}"); } + let preference = app.state::().store.snapshot().ui_theme; + if let Err(err) = state::window_theme::apply_main_window_theme(&window, preference) { + log::warn!("failed to synchronize main window theme: {err:#}"); + } + if cfg!(target_os = "windows") { if let Err(err) = window.set_decorations(false) { log::warn!("failed to disable decorations: {err}"); diff --git a/src-tauri/src/models/mod.rs b/src-tauri/src/models/mod.rs index dc59c88f6..a6cc4ebe5 100644 --- a/src-tauri/src/models/mod.rs +++ b/src-tauri/src/models/mod.rs @@ -2394,11 +2394,45 @@ pub struct LayerGroupDef { pub type LayerGroups = HashMap>; +#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)] +#[serde(rename_all = "lowercase")] +pub enum UiTheme { + #[default] + System, + Light, + Dark, +} + +impl TryFrom<&str> for UiTheme { + type Error = (); + + fn try_from(value: &str) -> Result { + match value { + "system" => Ok(Self::System), + "light" => Ok(Self::Light), + "dark" => Ok(Self::Dark), + _ => Err(()), + } + } +} + +impl UiTheme { + pub(crate) fn as_tauri_theme(self) -> Option { + match self { + Self::System => None, + Self::Light => Some(tauri::Theme::Light), + Self::Dark => Some(tauri::Theme::Dark), + } + } +} + #[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] #[serde(rename_all = "camelCase")] pub struct AppStoreData { pub hardware_acceleration: bool, pub always_on_top: bool, + #[serde(default)] + pub ui_theme: UiTheme, pub overlay_locked: bool, #[serde(default)] pub overlay_visible: bool, @@ -2500,6 +2534,7 @@ impl Default for AppStoreData { Self { hardware_acceleration: true, always_on_top: true, + ui_theme: UiTheme::default(), overlay_locked: false, overlay_visible: false, note_effect: false, @@ -2560,6 +2595,7 @@ impl AppStoreData { SettingsState { hardware_acceleration: self.hardware_acceleration, always_on_top: self.always_on_top, + ui_theme: self.ui_theme, overlay_locked: self.overlay_locked, note_effect: self.note_effect, note_settings: self.note_settings.clone(), @@ -2820,6 +2856,8 @@ pub struct DefaultsPayload { pub struct SettingsState { pub hardware_acceleration: bool, pub always_on_top: bool, + #[serde(default)] + pub ui_theme: UiTheme, pub overlay_locked: bool, pub note_effect: bool, #[serde(default)] @@ -2888,6 +2926,7 @@ pub struct NoteSettingsPatch { pub struct SettingsPatchInput { pub hardware_acceleration: Option, pub always_on_top: Option, + pub ui_theme: Option, pub overlay_locked: Option, pub note_effect: Option, pub note_settings: Option, @@ -2949,6 +2988,7 @@ impl SettingsDiff { [ p.hardware_acceleration.is_some(), p.always_on_top.is_some(), + p.ui_theme.is_some(), p.overlay_locked.is_some(), p.note_effect.is_some(), p.note_settings.is_some(), @@ -2984,6 +3024,8 @@ pub struct SettingsPatch { #[serde(skip_serializing_if = "Option::is_none")] pub always_on_top: Option, #[serde(skip_serializing_if = "Option::is_none")] + pub ui_theme: Option, + #[serde(skip_serializing_if = "Option::is_none")] pub overlay_locked: Option, #[serde(skip_serializing_if = "Option::is_none")] pub note_effect: Option, diff --git a/src-tauri/src/services/settings.rs b/src-tauri/src/services/settings.rs index abe79bd91..640dfc5fe 100644 --- a/src-tauri/src/services/settings.rs +++ b/src-tauri/src/services/settings.rs @@ -5,9 +5,9 @@ use anyhow::{Context, Result}; use crate::models::{ AppStoreData, CustomCss, CustomCssPatch, CustomJs, CustomJsPatch, FontType, NoteSettings, NoteSettingsPatch, SettingsDiff, SettingsPatch, SettingsPatchInput, SettingsState, - ShortcutsState, + ShortcutsState, UiTheme, }; -use crate::state::AppStore; +use crate::{errors::EditorCommitError, state::AppStore}; #[derive(Clone)] pub struct SettingsService { @@ -28,7 +28,7 @@ impl SettingsService { self.store.update(|state| { diff = Some(apply_patch_to_store(state, &patch)); })?; - diff.context("settings patch did not produce a diff") + diff.context("settings patch did not produce a diff")? } } @@ -36,16 +36,21 @@ pub(crate) fn settings_from_store(store: &AppStoreData) -> SettingsState { store.settings_state() } +pub(crate) fn settings_patch_validation_error(error: anyhow::Error) -> EditorCommitError { + EditorCommitError::validation("INVALID_SETTINGS_PATCH", error.to_string()) +} + pub(crate) fn apply_patch_to_store( store: &mut AppStoreData, patch: &SettingsPatchInput, -) -> SettingsDiff { +) -> Result { let current = settings_from_store(store); - let normalized = normalize_patch(patch, ¤t); + let normalized = normalize_patch(patch, ¤t)?; let next = apply_changes(current, &normalized); store.hardware_acceleration = next.hardware_acceleration; store.always_on_top = next.always_on_top; + store.ui_theme = next.ui_theme; store.overlay_locked = next.overlay_locked; store.note_effect = next.note_effect; store.note_settings = next.note_settings.clone(); @@ -67,13 +72,13 @@ pub(crate) fn apply_patch_to_store( store.shortcuts = next.shortcuts.clone(); store.obs_mode_enabled = next.obs_mode_enabled; - SettingsDiff { + Ok(SettingsDiff { changed: normalized, full: Some(next), - } + }) } -fn normalize_patch(patch: &SettingsPatchInput, current: &SettingsState) -> SettingsPatch { +fn normalize_patch(patch: &SettingsPatchInput, current: &SettingsState) -> Result { let mut normalized = SettingsPatch::default(); if let Some(value) = patch.hardware_acceleration { normalized.hardware_acceleration = Some(value); @@ -81,6 +86,12 @@ fn normalize_patch(patch: &SettingsPatchInput, current: &SettingsState) -> Setti if let Some(value) = patch.always_on_top { normalized.always_on_top = Some(value); } + if let Some(value) = patch.ui_theme.as_deref() { + normalized.ui_theme = Some( + UiTheme::try_from(value) + .map_err(|()| anyhow::anyhow!("invalid uiTheme value: {value}"))?, + ); + } if let Some(value) = patch.overlay_locked { normalized.overlay_locked = Some(value); } @@ -195,7 +206,7 @@ fn normalize_patch(patch: &SettingsPatchInput, current: &SettingsState) -> Setti if let Some(value) = patch.obs_mode_enabled { normalized.obs_mode_enabled = Some(value); } - normalized + Ok(normalized) } fn apply_changes(mut current: SettingsState, patch: &SettingsPatch) -> SettingsState { @@ -205,6 +216,9 @@ fn apply_changes(mut current: SettingsState, patch: &SettingsPatch) -> SettingsS if let Some(value) = patch.always_on_top { current.always_on_top = value; } + if let Some(value) = patch.ui_theme { + current.ui_theme = value; + } if let Some(value) = patch.overlay_locked { current.overlay_locked = value; } @@ -350,6 +364,7 @@ mod tests { assert!(d.hardware_acceleration); assert!(d.always_on_top); + assert_eq!(d.ui_theme, UiTheme::System); assert!(!d.overlay_locked); assert!(!d.note_effect); if cfg!(target_os = "macos") { @@ -499,6 +514,7 @@ mod tests { let expected_a = SettingsState { hardware_acceleration: false, always_on_top: false, + ui_theme: UiTheme::Light, overlay_locked: true, note_effect: true, note_settings: expected_note.clone(), @@ -531,6 +547,7 @@ mod tests { let patch_a = SettingsPatchInput { hardware_acceleration: Some(false), always_on_top: Some(false), + ui_theme: Some("light".to_string()), overlay_locked: Some(true), note_effect: Some(true), note_settings: Some(NoteSettingsPatch { @@ -579,13 +596,14 @@ mod tests { }; let mut store = AppStoreData::default(); - let diff_a = apply_patch_to_store(&mut store, &patch_a); + let diff_a = apply_patch_to_store(&mut store, &patch_a).expect("์ฒซ patch ์ ์šฉ"); assert_eq!(diff_a.full.expect("full snapshot ๋ฐ˜ํ™˜"), expected_a); assert_eq!(settings_from_store(&store), expected_a); // ๊ฐ™์€ ๊ธฐ๋ณธ๊ฐ’์„ ๊ณต์œ ํ•˜๋Š” bool ์Œ์ด ์„œ๋กœ ๋ฐ˜๋Œ€๊ฐ€ ๋˜๋„๋ก ์ ˆ๋ฐ˜๋งŒ ๋ฐ˜์ „ let patch_b = SettingsPatchInput { always_on_top: Some(true), + ui_theme: Some("dark".to_string()), note_effect: Some(false), developer_mode_enabled: Some(false), auto_update_enabled: Some(true), @@ -595,6 +613,7 @@ mod tests { }; let expected_b = SettingsState { always_on_top: true, + ui_theme: UiTheme::Dark, note_effect: false, developer_mode_enabled: false, auto_update_enabled: true, @@ -603,8 +622,8 @@ mod tests { ..expected_a }; - let diff_b = apply_patch_to_store(&mut store, &patch_b); - assert_eq!(diff_b.changed_count(), 6); + let diff_b = apply_patch_to_store(&mut store, &patch_b).expect("๋‘ ๋ฒˆ์งธ patch ์ ์šฉ"); + assert_eq!(diff_b.changed_count(), 7); assert_eq!(diff_b.full.expect("full snapshot ๋ฐ˜ํ™˜"), expected_b); assert_eq!(settings_from_store(&store), expected_b); // ๊ต์ฐจ ๋งคํ•‘ ๊ฒ€์ถœ ํ•ต์‹ฌ ์Œ, use_custom_css์™€ use_custom_js๊ฐ€ ์„œ๋กœ ๋ฐ˜๋Œ€ @@ -619,7 +638,8 @@ mod tests { let from_empty_store = settings_from_store(&AppStoreData::default()); let mut store = AppStoreData::default(); - let diff = apply_patch_to_store(&mut store, &SettingsPatchInput::default()); + let diff = apply_patch_to_store(&mut store, &SettingsPatchInput::default()) + .expect("๋นˆ patch ์ ์šฉ"); assert_eq!(diff.changed_count(), 0); let from_empty_patch = diff.full.expect("๋นˆ patch๋„ full snapshot ๋ฐ˜ํ™˜"); @@ -628,4 +648,37 @@ mod tests { // ๋นˆ patch๊ฐ€ store ๊ธฐ๋ณธ๊ฐ’์„ ๋ณ€๊ฒฝํ•˜์ง€ ์•Š์Œ assert_eq!(settings_from_store(&store), from_default); } + + #[test] + fn unknown_ui_theme_is_rejected_without_mutating_store() { + let mut store = AppStoreData::default(); + let before = store.clone(); + let patch = SettingsPatchInput { + ui_theme: Some("neon".to_string()), + ..SettingsPatchInput::default() + }; + + let error = apply_patch_to_store(&mut store, &patch).expect_err("๋ฏธ์ง€ ํ…Œ๋งˆ ๊ฑฐ์ ˆ"); + + assert!(error.to_string().contains("invalid uiTheme value: neon")); + assert_eq!(store, before); + } + + #[test] + fn ui_theme_uses_camel_case_in_settings_payloads() { + let mut store = AppStoreData::default(); + let diff = apply_patch_to_store( + &mut store, + &SettingsPatchInput { + ui_theme: Some("light".to_string()), + ..SettingsPatchInput::default() + }, + ) + .expect("ํ…Œ๋งˆ patch ์ ์šฉ"); + + let wire = serde_json::to_value(diff).expect("์„ค์ • diff ์ง๋ ฌํ™”"); + assert_eq!(wire["changed"]["uiTheme"], "light"); + assert_eq!(wire["full"]["uiTheme"], "light"); + assert!(wire["changed"].get("ui_theme").is_none()); + } } diff --git a/src-tauri/src/state/app_state.rs b/src-tauri/src/state/app_state.rs index 67fd8353c..27a423b83 100644 --- a/src-tauri/src/state/app_state.rs +++ b/src-tauri/src/state/app_state.rs @@ -48,7 +48,7 @@ use crate::{ CommittedEditorChange, DefaultsPayload, EditorCommitRequest, GestureCommitRequest, HistoryStatus, KeyCounterSettings, KeyCounters, KeyMappings, KeyPositions, KeySlot, KeySoundOutputBackendPersist, OverlayBounds, OverlayResizeAnchor, PanelBounds, - SettingsDiff, SettingsState, TabCssOverrides, + SettingsDiff, SettingsState, TabCssOverrides, UiTheme, }, services::{ css_watcher::CssWatcher, @@ -1341,7 +1341,7 @@ impl AppState { fn attach_main_window_handlers(&self, app: &AppHandle) { let overlay_force_close = self.overlay_force_close.clone(); if let Some(window) = app.get_webview_window("main") { - attach_main_window_close_handler(window, overlay_force_close, app.clone()); + attach_main_window_event_handler(window, overlay_force_close, app.clone()); return; } @@ -1350,7 +1350,7 @@ impl AppState { thread::spawn(move || { for _ in 0..15 { if let Some(window) = app_handle.get_webview_window("main") { - attach_main_window_close_handler( + attach_main_window_event_handler( window, overlay_force_close.clone(), app_handle.clone(), @@ -3607,6 +3607,10 @@ impl AppState { let snapshot = self.store.snapshot(); let stored_bounds = snapshot.panel_bounds; let layout = resolve_panel_window_layout(stored_bounds, main_rect, monitors, None); + let native_theme = app + .get_webview_window("main") + .and_then(|window| window.theme().ok()); + let resolved_theme = super::window_theme::resolve_theme(snapshot.ui_theme, native_theme); // about:blank๋Š” runtime-wry๊ฐ€ ์ดˆ๊ธฐ ๋„ค๋น„๊ฒŒ์ด์…˜์„ ๊ฑด๋„ˆ๋›ด๋‹ค - opener๊ฐ€ ๋ฌธ์„œ๋ฅผ ์ฑ„์šฐ๊ณ , // WebView2์˜ "NewWindow๋Š” ๋„ค๋น„๊ฒŒ์ด์…˜ ์ „์ด์–ด์•ผ ํ•จ" ์š”๊ฑด๋„ ์ด๊ฑธ๋กœ ๋งž๋Š”๋‹ค. @@ -3636,6 +3640,7 @@ impl AppState { // (์œ ํ‹ธ๋ฆฌํ‹ฐ ํŒจ๋„ ๊ด€๋ก€ - ๋ฒ„ํŠผ์ด ์ฒซ ํด๋ฆญ์— ๋ฐ”๋กœ ๋™์ž‘) .accept_first_mouse(true) .visible(false) + .theme(snapshot.ui_theme.as_tauri_theme()) .inner_size(PANEL_WIDTH, layout.height) .min_inner_size(PANEL_WIDTH, layout.min_height) .max_inner_size(PANEL_WIDTH, layout.max_height) @@ -3645,7 +3650,8 @@ impl AppState { // ์ด๋ฏธ ๋‚ด๋น„๊ฒŒ์ด์…˜์ด ์‹œ์ž‘๋œ ๋’ค๋ผ ๋Šฆ๋‹ค. ์‹ค์ œ ์ƒ‰์€ ๋ Œ๋”๋Ÿฌ๊ฐ€ ํ† ํฐ์„ ์ฝ์–ด ๋ฎ๋Š”๋‹ค #[cfg(target_os = "windows")] { - builder = builder.background_color(super::windows_window_corners::SEED_FILL); + builder = + builder.background_color(super::windows_window_corners::seed_fill(resolved_theme)); } if let Some(position) = layout.position { @@ -3671,7 +3677,7 @@ impl AppState { } } - self.configure_panel_window(&window, app, layout.max_height); + self.configure_panel_window(&window, app, layout.max_height, resolved_theme); Ok(window) } @@ -3680,6 +3686,7 @@ impl AppState { window: &WebviewWindow, app: &AppHandle, initial_max_height: f64, + _resolved_theme: tauri::Theme, ) { // ์›น ์ฝ˜ํ…์ธ ๊ฐ€ ๊ทธ๋ฆฌ๋Š” ๋ผ์šด๋”ฉ์€ ๋ฆฌ์‚ฌ์ด์ฆˆ ํ”„๋ ˆ์ž„์„ ๋ชป ๋”ฐ๋ผ์˜ด - ์‹ค๋ฃจ์—ฃ์€ ์ปดํฌ์ง€ํ„ฐ๊ฐ€ ์†Œ์œ  #[cfg(target_os = "macos")] @@ -3687,7 +3694,7 @@ impl AppState { // Windows๋Š” DWM์ด ์ด๋ฏธ ์ž๊ธฐ ๋ฐ˜๊ฒฝ์œผ๋กœ ์ž๋ฅด๊ณ  ์žˆ์–ด ์›น ๋ผ์šด๋”ฉ๊ณผ ์–ด๊ธ‹๋‚œ๋‹ค - ๋ฐ˜๊ฒฝ ์ง€์ •์ด // ๋ถˆ๊ฐ€๋Šฅํ•˜๋ฏ€๋กœ ์‹ค๋ฃจ์—ฃ์„ DWM์— ๋„˜๊ธฐ๊ณ  ์›น์€ ์‚ฌ๊ฐ์œผ๋กœ ์ฑ„์šด๋‹ค (๋ฉ”์ธ ์ฐฝ๊ณผ ๊ฐ™์€ ์ฒ˜๋ฆฌ) #[cfg(target_os = "windows")] - super::windows_window_corners::apply_initial_chrome(window); + super::windows_window_corners::apply_initial_chrome(window, _resolved_theme); let bounds_session = self .panel_bounds_persistence @@ -4073,6 +4080,19 @@ impl AppState { // ์˜ค๋ฒ„๋ ˆ์ด๊ฐ€ ๋ณด์ด๋Š” ์ƒํƒœ์ผ ๋•Œ๋งŒ ์„ค์ • ์ ์šฉ let is_visible = *self.overlay_visible.read(); + if let Some(value) = diff.changed.ui_theme { + if let Some(window) = app.get_webview_window("main") { + if let Err(err) = super::window_theme::apply_main_window_theme(&window, value) { + log::warn!("failed to synchronize main window theme: {err:#}"); + } + } + if let Some(window) = app.get_webview_window(PANEL_LABEL) { + if let Err(err) = window.set_theme(value.as_tauri_theme()) { + log::warn!("failed to apply panel window theme: {err}"); + } + } + } + if let Some(value) = diff.changed.always_on_top { if is_visible { if let Some(window) = app.get_webview_window(OVERLAY_LABEL) { @@ -5139,7 +5159,7 @@ mod output_backend_tests { } } -fn attach_main_window_close_handler( +fn attach_main_window_event_handler( window: WebviewWindow, overlay_force_close: Arc, app_handle: AppHandle, @@ -5153,8 +5173,17 @@ fn attach_main_window_close_handler( } let main_window = window.clone(); - window.on_window_event(move |event| { - if let WindowEvent::CloseRequested { api, .. } = event { + window.on_window_event(move |event| match event { + WindowEvent::ThemeChanged(theme) + if app_handle.state::().store.snapshot().ui_theme == UiTheme::System => + { + if let Err(err) = + super::window_theme::apply_main_window_background(&main_window, *theme) + { + log::warn!("failed to follow system theme for main window: {err:#}"); + } + } + WindowEvent::CloseRequested { api, .. } => { if overlay_force_close.load(Ordering::SeqCst) { return; } @@ -5179,6 +5208,7 @@ fn attach_main_window_close_handler( api.prevent_close(); state.request_frontend_shutdown(app_handle.clone()); } + _ => {} }); } diff --git a/src-tauri/src/state/mod.rs b/src-tauri/src/state/mod.rs index 8fe9092c7..890ffc8da 100644 --- a/src-tauri/src/state/mod.rs +++ b/src-tauri/src/state/mod.rs @@ -19,6 +19,7 @@ pub(crate) mod migration; pub(crate) mod native_element_id; pub(crate) mod plugin; pub mod store; +pub(crate) mod window_theme; #[cfg(target_os = "windows")] pub(crate) mod windows_window_corners; diff --git a/src-tauri/src/state/store.rs b/src-tauri/src/state/store.rs index c9e5a2486..f2f91bdda 100644 --- a/src-tauri/src/state/store.rs +++ b/src-tauri/src/state/store.rs @@ -1783,7 +1783,8 @@ impl AppStore { let settings_diff = crate::services::settings::apply_patch_to_store( &mut scratch, &preset_history_settings_patch(&target.settings), - ); + ) + .map_err(crate::services::settings::settings_patch_validation_error)?; crate::state::migration::canonicalize_gradient_pairs(&mut scratch); crate::state::migration::canonicalize_image_modes(&mut scratch); let candidate = EditorDocumentV1::from_store(&scratch); @@ -4848,8 +4849,8 @@ mod tests { NoteColor, OverlayBounds, PanelBounds, PendingProcessedWavReplacement, PluginInstancesCommitRequest, PluginInstancesReconcileRequest, PluginPoint, SavedPluginInstance, SettingsPatchInput, SlotMatch, SoundLibraryEntry, SoundSource, - StatPosition, StatType, TabCss, TabNoteSettings, EDITOR_COMMIT_SCHEMA_VERSION_V2, - EDITOR_OPS_VERSION, EDITOR_SCHEMA_VERSION, + StatPosition, StatType, TabCss, TabNoteSettings, UiTheme, + EDITOR_COMMIT_SCHEMA_VERSION_V2, EDITOR_OPS_VERSION, EDITOR_SCHEMA_VERSION, }, services::{css_watcher::commit_css_reload, settings::apply_patch_to_store}, state::{ @@ -17552,7 +17553,8 @@ mod tests { background_color: Some(format!("#{:06x}", index + 1)), ..SettingsPatchInput::default() }, - ); + ) + .expect("์„ค์ • patch ์ ์šฉ"); } Ok(()) }) @@ -17570,6 +17572,62 @@ mod tests { let _ = std::fs::remove_dir_all(dir); } + #[test] + fn ui_theme_change_does_not_invalidate_editor_history() { + let dir = test_directory("ui-theme-history-exclusion-test"); + std::fs::create_dir_all(&dir).unwrap(); + let store = AppStore::initialize_in_dir(&dir).unwrap(); + let initial_dx = store.editor_get().document.key_positions["4key"][0].dx; + + store + .commit_editor_document(editor_request( + store.editor_get().revision, + uuid::Uuid::new_v4().to_string(), + position_patch(&store, initial_dx + 1.0), + )) + .unwrap(); + let operation_id = uuid::Uuid::new_v4().to_string(); + let gate = store.history_gate(); + let barrier = gate.close(&operation_id).unwrap(); + let current_counters = store.snapshot().key_counters; + store + .apply_history_operation( + HistoryDirection::Undo, + &operation_id, + ¤t_counters, + || {}, + ) + .unwrap(); + drop(barrier); + let before = store.history_status(); + assert!(before.can_redo); + + let transaction = store + .commit_history_overlap_mutation(|data| { + apply_patch_to_store( + data, + &SettingsPatchInput { + ui_theme: Some("light".to_string()), + ..SettingsPatchInput::default() + }, + ) + .map_err(crate::services::settings::settings_patch_validation_error) + }) + .unwrap(); + + assert!(transaction.history_status.is_none()); + assert_eq!(store.snapshot().ui_theme, UiTheme::Light); + let after = store.history_status(); + assert_eq!(after.history_revision, before.history_revision); + assert_eq!(after.history_epoch, before.history_epoch); + assert_eq!(after.can_undo, before.can_undo); + assert_eq!(after.can_redo, before.can_redo); + assert_eq!(after.truncated, before.truncated); + + store.flush_and_shutdown().unwrap(); + let _ = std::fs::remove_dir_all(dir); + } + #[test] fn unrecorded_legacy_editor_mutations_invalidate_stale_undo_and_redo() { let dir = test_directory("legacy-editor-history-invalidation-test"); @@ -20330,7 +20388,8 @@ mod tests { background_color: Some("#123456".to_string()), ..SettingsPatchInput::default() }, - ); + ) + .expect("์„ค์ • patch ์ ์šฉ"); Ok(()) }, ) @@ -20356,7 +20415,8 @@ mod tests { background_color: Some("#FFFFFF".to_string()), ..SettingsPatchInput::default() }, - ); + ) + .expect("์„ค์ • patch ์ ์šฉ"); Ok(()) }, ); @@ -20810,6 +20870,22 @@ mod tests { let _ = std::fs::remove_dir_all(dir); } + #[test] + fn ui_theme_missing_field_defaults_to_system() { + let dir = test_directory("ui-theme-default-test"); + std::fs::create_dir_all(&dir).unwrap(); + let path = dir.join("store.json"); + let mut fixture = serde_json::to_value(AppStoreData::default()).unwrap(); + assert!(fixture.as_object_mut().unwrap().remove("uiTheme").is_some()); + std::fs::write(&path, serde_json::to_vec_pretty(&fixture).unwrap()).unwrap(); + + let store = AppStore::initialize_in_dir(&dir).unwrap(); + assert_eq!(store.snapshot().ui_theme, UiTheme::System); + store.flush_and_shutdown().unwrap(); + drop(store); + let _ = std::fs::remove_dir_all(dir); + } + #[test] fn regular_update_commits_pending_deferred_state() { let dir = test_directory("store-deferred-regular-update-test"); diff --git a/src-tauri/src/state/window_theme.rs b/src-tauri/src/state/window_theme.rs new file mode 100644 index 000000000..ab0dc3b51 --- /dev/null +++ b/src-tauri/src/state/window_theme.rs @@ -0,0 +1,74 @@ +use anyhow::{Context, Result}; +use tauri::{window::Color, Theme, WebviewWindow}; + +use crate::models::UiTheme; + +// tokens/dark.css, tokens/light.css์˜ --ui-bg-app๊ณผ ๊ฐ™์€ ๊ฐ’ +const MAIN_DARK_BACKGROUND: Color = Color(0x13, 0x13, 0x15, 0xFF); +const MAIN_LIGHT_BACKGROUND: Color = Color(0xCC, 0xD3, 0xDC, 0xFF); + +pub(crate) fn resolve_theme(preference: UiTheme, native_theme: Option) -> Theme { + match preference { + UiTheme::Light => Theme::Light, + UiTheme::Dark => Theme::Dark, + UiTheme::System => native_theme.unwrap_or(Theme::Dark), + } +} + +fn main_background_color(theme: Theme) -> Color { + match theme { + Theme::Light => MAIN_LIGHT_BACKGROUND, + Theme::Dark => MAIN_DARK_BACKGROUND, + _ => MAIN_DARK_BACKGROUND, + } +} + +pub(crate) fn apply_main_window_background(window: &WebviewWindow, theme: Theme) -> Result<()> { + window + .set_background_color(Some(main_background_color(theme))) + .context("failed to apply main window background color") +} + +pub(crate) fn apply_main_window_theme(window: &WebviewWindow, preference: UiTheme) -> Result<()> { + window + .set_theme(preference.as_tauri_theme()) + .context("failed to apply main window theme")?; + + // System ์ „ํ™˜ ์งํ›„์˜ ์‹ค์ œ ํ…Œ๋งˆ๋ฅผ ์ฝ์–ด ๋„ค์ดํ‹ฐ๋ธŒ ํ‘œ๋ฉด๊ณผ ๊ฐ™์€ ์ƒ‰์„ ์‚ฌ์šฉ + let native_theme = (preference == UiTheme::System) + .then(|| window.theme().ok()) + .flatten(); + apply_main_window_background(window, resolve_theme(preference, native_theme)) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn explicit_theme_ignores_native_theme() { + assert_eq!( + resolve_theme(UiTheme::Light, Some(Theme::Dark)), + Theme::Light + ); + assert_eq!( + resolve_theme(UiTheme::Dark, Some(Theme::Light)), + Theme::Dark + ); + } + + #[test] + fn system_theme_uses_native_theme_with_dark_fallback() { + assert_eq!( + resolve_theme(UiTheme::System, Some(Theme::Light)), + Theme::Light + ); + assert_eq!(resolve_theme(UiTheme::System, None), Theme::Dark); + } + + #[test] + fn background_color_matches_resolved_theme() { + assert_eq!(main_background_color(Theme::Light), MAIN_LIGHT_BACKGROUND); + assert_eq!(main_background_color(Theme::Dark), MAIN_DARK_BACKGROUND); + } +} diff --git a/src-tauri/src/state/windows_window_corners.rs b/src-tauri/src/state/windows_window_corners.rs index 581550110..800efe632 100644 --- a/src-tauri/src/state/windows_window_corners.rs +++ b/src-tauri/src/state/windows_window_corners.rs @@ -1,7 +1,7 @@ // lib์™€ bin์˜ ๋…๋ฆฝ state ๋ชจ๋“ˆ ์ปดํŒŒ์ผ #![allow(dead_code)] -use tauri::{window::Color, WebviewWindow}; +use tauri::{window::Color, Theme, WebviewWindow}; use windows::Win32::Foundation::COLORREF; use windows::Win32::Graphics::Dwm::{ DwmSetWindowAttribute, DWMWA_BORDER_COLOR, DWMWA_WINDOW_CORNER_PREFERENCE, DWMWCP_ROUND, @@ -27,24 +27,45 @@ use windows::Win32::Graphics::Dwm::{ // ์ฒซ ํŽ˜์ธํŠธ ์ „ ๊ตฌ๊ฐ„์„ ๋ฉ”์šฐ๋Š” ์”จ์•—๊ฐ’ - tokens.css์˜ --ui-bg-panel-detached์™€ ๊ฐ™์€ ๊ฐ’์„ ์œ ์ง€ํ•œ๋‹ค. // ์ฐฝ ๋นŒ๋”์— ์‹ค๋ ค WebView2 ์ปจํŠธ๋กค๋Ÿฌ ์ƒ์„ฑ ์˜ต์…˜์œผ๋กœ ๋“ค์–ด๊ฐ€๋ฏ€๋กœ ์ฒซ ํ”„๋ ˆ์ž„๋ถ€ํ„ฐ ์œ ํšจํ•˜๊ณ , // ์‹ค์ œ ์ƒ‰์€ ๋ Œ๋”๋Ÿฌ๊ฐ€ ํ† ํฐ์„ ์ฝ์–ด apply_surface_chrome์œผ๋กœ ๋ฎ๋Š”๋‹ค -pub(crate) const SEED_FILL: Color = Color(0x1C, 0x1C, 0x1E, 0xFF); +const DARK_SEED_FILL: Color = Color(0x1C, 0x1C, 0x1E, 0xFF); +const LIGHT_SEED_FILL: Color = Color(0xEE, 0xF3, 0xF7, 0xFF); // tokens.css์˜ --ui-line๊ณผ ๊ฐ™์€ ๊ฐ’. DWM ๊ธฐ๋ณธ ๋ณด๋”๋Š” ์‹œ์Šคํ…œ ์ƒ‰์ด๋ผ ํ›จ์”ฌ ์ง„ํ•ด์„œ, ๋ Œ๋”๋Ÿฌ๊ฐ€ // ํ† ํฐ์„ ์ฝ์–ด ๋ฎ๋Š” ์ˆœ๊ฐ„ ์„  ๊ตต๊ธฐ๊ฐ€ ๋ฐ”๋€ ๊ฒƒ์ฒ˜๋Ÿผ ๋ณด์ธ๋‹ค - ์ฐฝ ์ƒ์„ฑ ์‹œ์ ์— ๋ฏธ๋ฆฌ ๋งž์ถฐ ์ „ํ™˜์„ ์—†์•ค๋‹ค -const SEED_LINE: [f64; 4] = [1.0, 1.0, 1.0, 0.1]; +const DARK_SEED_LINE: [f64; 4] = [1.0, 1.0, 1.0, 0.1]; +// ๋ผ์ดํŠธ --ui-line์€ ์ˆœํ‘์ด ์•„๋‹ˆ๋ผ ์‚ด์ง ๋‚จ์ƒ‰์œผ๋กœ ๊ธฐ์šด ์ž‰ํฌ๋‹ค. +// ๋ฌด์ฑ„์ƒ‰ ์œ„ ์ˆœํ‘ ์ €์•ŒํŒŒ๋Š” ๋•Œ๊ฐ€ ํƒ„ ๊ฒƒ์ฒ˜๋Ÿผ ๋ณด์ธ๋‹ค +const LIGHT_SEED_LINE: [f64; 4] = [0.062745, 0.094118, 0.156863, 0.12]; + +pub(crate) fn seed_fill(theme: Theme) -> Color { + match theme { + Theme::Light => LIGHT_SEED_FILL, + Theme::Dark => DARK_SEED_FILL, + _ => DARK_SEED_FILL, + } +} + +fn seed_line(theme: Theme) -> [f64; 4] { + match theme { + Theme::Light => LIGHT_SEED_LINE, + Theme::Dark => DARK_SEED_LINE, + _ => DARK_SEED_LINE, + } +} -fn seed_fill_components() -> [f64; 4] { +fn seed_fill_components(theme: Theme) -> [f64; 4] { + let fill = seed_fill(theme); [ - f64::from(SEED_FILL.0) / 255.0, - f64::from(SEED_FILL.1) / 255.0, - f64::from(SEED_FILL.2) / 255.0, + f64::from(fill.0) / 255.0, + f64::from(fill.1) / 255.0, + f64::from(fill.2) / 255.0, 1.0, ] } // ์ฐฝ ์ƒ์„ฑ ์‹œ์  - ๊ฒฐ๊ณผ๋ฅผ ๊ธฐ๋‹ค๋ฆด ์†Œ๋น„์ž๊ฐ€ ์—†์–ด fire-and-forget. // Win11์˜ ๊ธฐ๋ณธ๊ฐ’์ด ์ด๋ฏธ ๋ผ์šด๋“œ์ง€๋งŒ, ์ฐฝ ์ข…๋ฅ˜์— ๋”ฐ๋ผ ๋‹ฌ๋ผ์ง€๋Š” ํœด๋ฆฌ์Šคํ‹ฑ์— ๊ธฐ๋Œ€์ง€ ์•Š๋Š”๋‹ค -pub(crate) fn apply_initial_chrome(window: &WebviewWindow) { +pub(crate) fn apply_initial_chrome(window: &WebviewWindow, theme: Theme) { match set_corner_preference(window) { Ok(()) => log::info!( "[window-corners] applied native corner preference to '{}'", @@ -56,8 +77,10 @@ pub(crate) fn apply_initial_chrome(window: &WebviewWindow) { ), } - if let Err(reason) = set_border_color(window, composite_over(SEED_LINE, seed_fill_components())) - { + if let Err(reason) = set_border_color( + window, + composite_over(seed_line(theme), seed_fill_components(theme)), + ) { log::debug!( "[window-corners] skipped seed border color for '{}': {reason}", window.label() @@ -170,7 +193,8 @@ fn to_colorref(rgb: [f64; 3]) -> COLORREF { #[cfg(test)] mod tests { - use super::{composite_over, to_colorref}; + use super::{composite_over, seed_fill_components, seed_line, to_colorref}; + use tauri::Theme; #[test] fn opaque_line_replaces_the_fill() { @@ -190,15 +214,20 @@ mod tests { // --ui-line rgba(255,255,255,0.1)์„ --ui-bg-panel-detached #1c1c1e ์œ„์— ์˜ฌ๋ฆฐ ๊ฐ’ #[test] - fn translucent_line_blends_toward_the_fill() { - let blended = composite_over( - [1.0, 1.0, 1.0, 0.1], - [28.0 / 255.0, 28.0 / 255.0, 30.0 / 255.0, 1.0], - ); + fn dark_translucent_line_blends_toward_the_fill() { + let blended = composite_over(seed_line(Theme::Dark), seed_fill_components(Theme::Dark)); assert_eq!(super::channel(blended[0]), 51); assert_eq!(super::channel(blended[2]), 53); } + // --ui-line rgba(16,24,40,0.12)์„ --ui-bg-panel-detached #eef3f7 ์œ„์— ์˜ฌ๋ฆฐ ๊ฐ’ + #[test] + fn light_translucent_line_blends_toward_the_fill() { + let blended = composite_over(seed_line(Theme::Light), seed_fill_components(Theme::Light)); + assert_eq!(super::channel(blended[0]), 211); + assert_eq!(super::channel(blended[2]), 222); + } + #[test] fn colorref_packs_blue_into_the_high_byte() { assert_eq!(to_colorref([1.0, 0.0, 0.0]).0, 0x0000_00FF); diff --git a/src/renderer/__tests__/digitPopAnimationContract.test.ts b/src/renderer/__tests__/digitPopAnimationContract.test.ts index 3e07aadc5..bfbda96ae 100644 --- a/src/renderer/__tests__/digitPopAnimationContract.test.ts +++ b/src/renderer/__tests__/digitPopAnimationContract.test.ts @@ -7,7 +7,7 @@ const mainCss = readFileSync( 'utf8', ); const tokensCss = readFileSync( - resolve(process.cwd(), 'src/renderer/styles/tokens.css'), + resolve(process.cwd(), 'src/renderer/styles/tokens/base.css'), 'utf8', ); diff --git a/src/renderer/__tests__/glassCanvasSurfaceContract.test.ts b/src/renderer/__tests__/glassCanvasSurfaceContract.test.ts index 856461fbe..581b675fd 100644 --- a/src/renderer/__tests__/glassCanvasSurfaceContract.test.ts +++ b/src/renderer/__tests__/glassCanvasSurfaceContract.test.ts @@ -2,23 +2,40 @@ import { readFileSync } from 'node:fs'; import { resolve } from 'node:path'; import { describe, expect, it } from 'vitest'; -const tokensCss = readFileSync( - resolve(process.cwd(), 'src/renderer/styles/tokens.css'), - 'utf8', -); -const mainCss = readFileSync( - resolve(process.cwd(), 'src/renderer/styles/main.css'), - 'utf8', -); -const tailwindConfig = readFileSync( - resolve(process.cwd(), 'tailwind.config.js'), - 'utf8', -); +const read = (path: string) => + readFileSync(resolve(process.cwd(), path), 'utf8'); + +const darkCss = read('src/renderer/styles/tokens/dark.css'); +const lightCss = read('src/renderer/styles/tokens/light.css'); +const mainCss = read('src/renderer/styles/main.css'); +const tailwindConfig = read('tailwind.config.js'); + +const themes: [string, string][] = [ + ['dark', darkCss], + ['light', lightCss], +]; + +const declared = (css: string, token: string): string | null => { + const match = css.match(new RegExp(`${token}:\\s*([^;]+);`)); + return match ? match[1].trim() : null; +}; describe('canvas ์œ„ glass ํ‘œ๋ฉด ๊ณ„์•ฝ', () => { - it('์ผ๋ฐ˜ glass์™€ ๋ถ„๋ฆฌ๋œ ๋ฐ๊ธฐยท์ฑ„๋„ clamp๋ฅผ ๊ฐ€์ง„๋‹ค', () => { - expect(tokensCss).toMatch(/--ui-glass-canvas-backdrop-dim:\s*0\.62/); - expect(tokensCss).toMatch(/--ui-glass-canvas-backdrop-sat:\s*0\.35/); + it('์ผ๋ฐ˜ glass์™€ ๋ถ„๋ฆฌ๋œ ๋ฐ๊ธฐยท๋Œ€๋น„ยท์ฑ„๋„ clamp๋ฅผ ๊ฐ€์ง„๋‹ค', () => { + // ์บ”๋ฒ„์Šค๋Š” ์‚ฌ์šฉ์ž๊ฐ€ ๋งŒ๋“  ์ž„์˜ ๋ฐ๊ธฐ๋ผ ์ผ๋ฐ˜ ํ‘œ๋ฉด๊ณผ ๋‹ค๋ฅธ ์„ธ๊ธฐ๊ฐ€ ํ•„์š”ํ•˜๋‹ค. + // ๊ฐ’์ด ๊ฐ™์•„์ง€๋ฉด ๋ถ„๋ฆฌํ•ด ๋‘” ์ด์œ ๊ฐ€ ์‚ฌ๋ผ์ง€๋ฏ€๋กœ ๋‹ค๋ฅด๋‹ค๋Š” ๊ฒƒ ์ž์ฒด๋ฅผ ๊ณ„์•ฝ์œผ๋กœ ๊ณ ์ • + for (const [name, css] of themes) { + for (const dial of ['dim', 'contrast', 'sat']) { + const base = declared(css, `--ui-glass-backdrop-${dial}`); + const canvas = declared(css, `--ui-glass-canvas-backdrop-${dial}`); + expect(base, `${name} ${dial}`).not.toBeNull(); + expect(canvas, `${name} canvas ${dial}`).not.toBeNull(); + } + expect( + declared(css, '--ui-glass-canvas-backdrop-sat'), + `${name} canvas sat`, + ).not.toBe(declared(css, '--ui-glass-backdrop-sat')); + } expect(mainCss).toContain( ':where(.backdrop-glass-canvas).backdrop-glass {', ); @@ -27,7 +44,36 @@ describe('canvas ์œ„ glass ํ‘œ๋ฉด ๊ณ„์•ฝ', () => { ); }); - it('ํˆฌ๋ช…๋„ ์ถ•์†Œ media ๊ทœ์น™๋ณด๋‹ค ์•ž์—์„œ ์„ ์–ธ๋œ๋‹ค', () => { + it('ํ•„ํ„ฐ ์ฒด์ธ์ด ์„ธ ๋‹ค์ด์–ผ์„ ๋ชจ๋‘ ์†Œ๋น„ํ•œ๋‹ค', () => { + // ํ† ํฐ๋งŒ ๋Š˜๋ฆฌ๊ณ  ์ฒด์ธ์— ์•ˆ ๋„ฃ์œผ๋ฉด ๋ผ์ดํŠธ์˜ ๋ฒ”์œ„ ์••์ถ•์ด ์กฐ์šฉํžˆ ์ฃฝ๋Š”๋‹ค + const filters: string[] = mainCss.match(/backdrop-filter:[^;]+;/g) ?? []; + const clamped = filters.filter((rule) => rule.includes('brightness(')); + expect(clamped.length).toBeGreaterThan(0); + for (const rule of clamped) { + expect(rule).toContain('contrast('); + expect(rule).toContain('saturate('); + } + }); + + it('ํˆฌ๋ช…๋„ ์ถ•์†Œ ํด๋ฐฑ์ด ํ…Œ๋งˆ ํ† ํฐ์„ ๊ฑฐ์นœ๋‹ค', () => { + // ๋ฆฌํ„ฐ๋Ÿด๋กœ ๋‘๋ฉด ์š”์†Œ ์Šค์ฝ”ํ”„ ์„ ์–ธ์ด ํ…Œ๋งˆ ๋ฃจํŠธ๋ฅผ ์ด๊ฒจ ๋ผ์ดํŠธ์—์„œ ๋‹คํฌ ๋ฉด์ด ๋œฌ๋‹ค + const reduced = mainCss.slice( + mainCss.indexOf('@media (prefers-reduced-transparency: reduce)'), + ); + expect(reduced).not.toMatch(/--ui-glass[\w-]*:\s*rgba?\(/); + for (const [name, css] of themes) { + for (const token of [ + '--ui-glass-reduced', + '--ui-glass-heavy-reduced', + '--ui-glass-dim-reduced', + '--ui-glass-panel-reduced', + ]) { + expect(declared(css, token), `${name} ${token}`).not.toBeNull(); + } + } + }); + + it('canvas ๊ทœ์น™์ด ํˆฌ๋ช…๋„ ์ถ•์†Œ media ๊ทœ์น™๋ณด๋‹ค ์•ž์—์„œ ์„ ์–ธ๋œ๋‹ค', () => { const canvasSurfaceRule = mainCss.indexOf( ':where(.backdrop-glass-canvas).backdrop-glass {', ); @@ -38,12 +84,29 @@ describe('canvas ์œ„ glass ํ‘œ๋ฉด ๊ณ„์•ฝ', () => { expect(reducedTransparencyRule).toBeGreaterThan(canvasSurfaceRule); }); - it('popup ์ „์šฉ ์งง์€ shadow๊ฐ€ theme์— ์—ฐ๊ฒฐ๋œ๋‹ค', () => { - expect(tokensCss).toMatch( - /--ui-shadow-popup:[\s\S]*?0 3px 10px rgba\(0, 0, 0, 0\.28\)/, - ); + it('popup ์ „์šฉ ์งง์€ shadow๊ฐ€ ๋‘ ํ…Œ๋งˆ์— ๋ชจ๋‘ ์—ฐ๊ฒฐ๋œ๋‹ค', () => { + for (const [name, css] of themes) { + const popup = declared(css, '--ui-shadow-popup'); + const modal = declared(css, '--ui-shadow-3'); + expect(popup, `${name} popup`).not.toBeNull(); + // ํŒ์—…์€ ๋ชจ๋‹ฌ์˜ ๋„“์€ ๋ฒˆ์ง์„ ์žฌ์‚ฌ์šฉํ•˜์ง€ ์•Š๋Š”๋‹ค + expect(popup, `${name} popup`).not.toBe(modal); + } expect(tailwindConfig).toContain( "'elevation-popup': 'var(--ui-shadow-popup)'", ); }); + + it('๋ฃจ๋ฏธ๋…ธ์‹œํ‹ฐ ๋ ˆ์ด์–ด์˜ ๋ชฉํ‘œ ํœ˜๋„๊ฐ€ glass ํ‹ดํŠธ์™€ ๊ฐ™์€ ์ƒ‰์ด๋‹ค', () => { + // ํ‹ดํŠธ๋งŒ ์˜ฌ๋ฆฌ๊ณ  ์—ฌ๊ธฐ๋ฅผ ๋‘๋ฉด ์บก์ด ํœ˜๋„๋ฅผ ๋„๋กœ ๋Œ์–ด๋‹น๊ธด๋‹ค + for (const [name, css] of themes) { + const tint = declared(css, '--ui-glass-backdrop-lum-tint'); + const glass = declared(css, '--ui-glass'); + expect(tint, `${name} lum tint`).toMatch(/^#[0-9a-f]{6}$/); + const [r, g, b] = [1, 3, 5].map((i) => + parseInt((tint as string).slice(i, i + 2), 16), + ); + expect(glass, `${name} glass`).toContain(`rgba(${r}, ${g}, ${b},`); + } + }); }); diff --git a/src/renderer/__tests__/themeTokenParityContract.test.ts b/src/renderer/__tests__/themeTokenParityContract.test.ts new file mode 100644 index 000000000..7a413159a --- /dev/null +++ b/src/renderer/__tests__/themeTokenParityContract.test.ts @@ -0,0 +1,176 @@ +import { globSync, readFileSync } from 'node:fs'; +import { resolve } from 'node:path'; +import { describe, expect, it } from 'vitest'; + +const read = (name: string) => + readFileSync(resolve(process.cwd(), 'src/renderer/styles', name), 'utf8'); + +const barrelCss = read('tokens.css'); +const baseCss = read('tokens/base.css'); +const darkCss = read('tokens/dark.css'); +const lightCss = read('tokens/light.css'); +const tailwindConfig = readFileSync( + resolve(process.cwd(), 'tailwind.config.js'), + 'utf8', +); + +const stripComments = (css: string) => css.replace(/\/\*[\s\S]*?\*\//g, ''); + +// ์„ ์–ธ๋งŒ ๋ฝ‘๋Š”๋‹ค. var(--x) ์ฐธ์กฐ๋Š” ์ฝœ๋ก ์ด ์—†์–ด ๊ฑธ๋ฆฌ์ง€ ์•Š๋Š”๋‹ค +const declaredKeys = (css: string): string[] => + Array.from(stripComments(css).matchAll(/(--[\w-]+)\s*:/g)).map((m) => m[1]); + +const darkKeys = declaredKeys(darkCss); +const lightKeys = declaredKeys(lightCss); + +const tokenHex = (css: string, key: string): string => { + const match = stripComments(css).match( + new RegExp(`${key}\\s*:\\s*(#[0-9a-fA-F]{6})\\b`), + ); + if (!match) throw new Error(`${key} hex token not found`); + return match[1]; +}; + +const relativeLuminance = (hex: string): number => { + const channels = hex + .slice(1) + .match(/.{2}/g)! + .map((channel) => Number.parseInt(channel, 16) / 255) + .map((channel) => + channel <= 0.04045 ? channel / 12.92 : ((channel + 0.055) / 1.055) ** 2.4, + ); + return channels[0] * 0.2126 + channels[1] * 0.7152 + channels[2] * 0.0722; +}; + +const contrastRatio = (foreground: string, background: string): number => { + const foregroundLuminance = relativeLuminance(foreground); + const backgroundLuminance = relativeLuminance(background); + return ( + (Math.max(foregroundLuminance, backgroundLuminance) + 0.05) / + (Math.min(foregroundLuminance, backgroundLuminance) + 0.05) + ); +}; + +const labLightness = (hex: string): number => { + const luminance = relativeLuminance(hex); + const threshold = 216 / 24389; + const transformed = + luminance > threshold + ? Math.cbrt(luminance) + : (24389 / 27 / 116) * luminance + 16 / 116; + return 116 * transformed - 16; +}; + +describe('ํ…Œ๋งˆ ํ† ํฐ ํŒŒ๋ฆฌํ‹ฐ ๊ณ„์•ฝ', () => { + it('๋‹คํฌ์™€ ๋ผ์ดํŠธ๊ฐ€ ๊ฐ™์€ ํ‚ค๋ฅผ ๊ฐ™์€ ์ˆœ์„œ๋กœ ์„ ์–ธํ•œ๋‹ค', () => { + // ์ˆœ์„œ๊นŒ์ง€ ๋ฌถ์–ด ๋‘๋ฉด ๋‘ ํŒŒ์ผ์„ ๋‚˜๋ž€ํžˆ ๋†“๊ณ  ์ฝ์„ ์ˆ˜ ์žˆ๋‹ค + expect(lightKeys).toEqual(darkKeys); + }); + + it('ํ‚ค๊ฐ€ ์ค‘๋ณต ์„ ์–ธ๋˜์ง€ ์•Š๋Š”๋‹ค', () => { + expect(new Set(darkKeys).size).toBe(darkKeys.length); + expect(new Set(lightKeys).size).toBe(lightKeys.length); + }); + + it('ํ…Œ๋งˆ ํŒŒ์ผ์€ :root ํด๋ฐฑ์„ ๋‘์ง€ ์•Š๋Š”๋‹ค', () => { + // ๋‹คํฌ๊ฐ€ bare :root์— ๋‚จ์œผ๋ฉด ๋ผ์ดํŠธ์—์„œ ๋น ์ง„ ํ† ํฐ์ด ์กฐ์šฉํžˆ ๋‹คํฌ๋กœ ์ƒˆ์–ด๋‚˜๊ฐ„๋‹ค. + // :where()๋กœ ๊ฐ์‹ธ๋Š” ์ด์œ ๋Š” ์‚ฌ์šฉ์ž CSS์˜ :root ์žฌ์ •์˜์™€ ํŠน์ด๋„๋ฅผ ๋งž์ถ”๊ธฐ ์œ„ํ•จ + expect(stripComments(darkCss)).toContain( + ":root:where([data-theme='dark']) {", + ); + expect(stripComments(lightCss)).toContain( + ":root:where([data-theme='light']) {", + ); + expect(stripComments(darkCss)).not.toMatch(/(^|\})\s*:root\s*\{/); + expect(stripComments(lightCss)).not.toMatch(/(^|\})\s*:root\s*\{/); + }); + + it('base๋Š” ์ƒ‰์„ ์†Œ์œ ํ•˜์ง€ ์•Š๋Š”๋‹ค', () => { + const body = stripComments(baseCss); + expect(body).not.toMatch(/#[0-9a-fA-F]{3,8}\b/); + expect(body).not.toMatch(/\b(rgba?|hsla?|color-mix|oklch)\(/); + expect(body).not.toMatch(/:\s*(white|black)\b/); + }); + + it('ํ…Œ๋งˆ ํŒŒ์ผ์€ ๊ตฌ์กฐ ํ† ํฐ์„ ์†Œ์œ ํ•˜์ง€ ์•Š๋Š”๋‹ค', () => { + const structural = /(radius|duration|ease|stagger|scale|^--z-)/; + expect(darkKeys.filter((key) => structural.test(key))).toEqual([]); + expect(lightKeys.filter((key) => structural.test(key))).toEqual([]); + }); + + it('๋ฐฐ๋Ÿด์ด ์„ธ ํŒŒ์ผ์„ ์ˆœ์„œ๋Œ€๋กœ ๋กœ๋“œํ•œ๋‹ค', () => { + const order = Array.from( + barrelCss.matchAll(/@import\s+'\.\/tokens\/([\w-]+)\.css'/g), + ).map((m) => m[1]); + expect(order).toEqual(['base', 'dark', 'light']); + }); + + it('tailwind๊ฐ€ ์ฐธ์กฐํ•˜๋Š” ํ† ํฐ์ด ์–‘์ชฝ ํ…Œ๋งˆ์— ๋ชจ๋‘ ์žˆ๋‹ค', () => { + const referenced = new Set( + Array.from(tailwindConfig.matchAll(/var\((--ui-[\w-]+)\)/g)).map( + (m) => m[1], + ), + ); + const baseKeys = new Set(declaredKeys(baseCss)); + const missing = Array.from(referenced).filter( + (key) => + !baseKeys.has(key) && + !(darkKeys.includes(key) && lightKeys.includes(key)), + ); + expect(missing).toEqual([]); + }); + + it('์†Œ์Šค๊ฐ€ ์ฐธ์กฐํ•˜๋Š” ํ† ํฐ์ด ์–‘์ชฝ ํ…Œ๋งˆ์— ๋ชจ๋‘ ์žˆ๋‹ค', () => { + // ํ•œ์ชฝ ํ…Œ๋งˆ์—๋งŒ ๊ฐ’์ด ์žˆ๊ฑฐ๋‚˜ ์•„๋ฌด ๋ฐ๋„ ์—†๋Š” ํ† ํฐ์„ ์“ฐ๊ธฐ ์‹œ์ž‘ํ•œ ์ฝ”๋“œ๋ฅผ ๋ง‰๋Š”๋‹ค. + // ์‚ฌ์šฉ์ž ์ฝ˜ํ…์ธ  ๋ณ€์ˆ˜(--dmn-*, --key-*, --counter-* ๋“ฑ)๋Š” ์•ฑ ํ…Œ๋งˆ ๋Œ€์ƒ์ด ์•„๋‹ˆ๋‹ค + const known = new Set([ + ...declaredKeys(baseCss), + ...darkKeys.filter((key) => lightKeys.includes(key)), + ]); + const sources = [ + ...globSync('src/renderer/**/*.{ts,tsx}'), + ...globSync('src/renderer/styles/*.css'), + ].filter((file) => !file.includes('__tests__') && !file.includes('.test.')); + + const missing = new Map(); + for (const file of sources) { + const body = readFileSync(resolve(process.cwd(), file), 'utf8'); + for (const match of body.matchAll(/var\((--ui-[\w-]+)/g)) { + const key = match[1]; + if (known.has(key) || missing.has(key)) continue; + missing.set(key, file); + } + } + expect(Object.fromEntries(missing)).toEqual({}); + }); + + it('๋ผ์ดํŠธ ์ €๊ฐ•๋„ ์ฝ๊ธฐ ํ…์ŠคํŠธ๊ฐ€ ์•ฑ ๋ฐ”๋‹ฅ๊ณผ ํŒจ๋„์—์„œ AA ๋Œ€๋น„๋ฅผ ์ง€ํ‚จ๋‹ค', () => { + for (const foreground of ['--ui-fg-muted', '--ui-fg-caption']) { + for (const surface of [ + '--ui-bg-app', + '--ui-bg-panel', + '--ui-bg-panel-detached', + ]) { + expect( + contrastRatio( + tokenHex(lightCss, foreground), + tokenHex(lightCss, surface), + ), + ).toBeGreaterThanOrEqual(4.5); + } + } + }); + + it('๋ผ์ดํŠธ muted, caption, ์žฅ์‹ faint์˜ ๋ช…๋„ ๋‹จ๊ณ„๋ฅผ ๋ถ„๋ฆฌํ•œ๋‹ค', () => { + const muted = tokenHex(lightCss, '--ui-fg-muted'); + const caption = tokenHex(lightCss, '--ui-fg-caption'); + const faint = tokenHex(lightCss, '--ui-fg-faint'); + const mutedLightness = labLightness(muted); + const captionLightness = labLightness(caption); + const faintLightness = labLightness(faint); + + expect(captionLightness).toBeGreaterThan(mutedLightness); + expect(faintLightness).toBeGreaterThan(captionLightness); + expect(faintLightness - mutedLightness).toBeGreaterThanOrEqual(8); + }); +}); diff --git a/src/renderer/__tests__/zIndexLayerContract.test.ts b/src/renderer/__tests__/zIndexLayerContract.test.ts index 261685024..f5a246400 100644 --- a/src/renderer/__tests__/zIndexLayerContract.test.ts +++ b/src/renderer/__tests__/zIndexLayerContract.test.ts @@ -5,7 +5,7 @@ import { describe, expect, it } from 'vitest'; const readSource = (path: string) => readFileSync(resolve(process.cwd(), path), 'utf8'); -const TOKENS = readSource('src/renderer/styles/tokens.css'); +const TOKENS = readSource('src/renderer/styles/tokens/base.css'); const tokenValue = (name: string): number => { const match = TOKENS.match(new RegExp(`--${name}:\\s*(-?\\d+);`)); @@ -91,7 +91,7 @@ describe('z-index ์ธต ๊ณ„์•ฝ', () => { }); // ์ปดํฌ๋„ŒํŠธ ์•ˆ์—์„œ๋งŒ ๊ฒจ๋ฃจ๋Š” ์ž‘์€ ๊ฐ’(z-10 ๋“ฑ)์€ ๊ณ„์•ฝ ๋ฐ–์ด๋‹ค. - // ์‚ฌ๋‹ค๋ฆฌ ๊ตฌ๊ฐ„์„ ๋งจ์ˆซ์ž๋กœ ์“ฐ๋ฉด tokens.css๊ฐ€ ๋‹จ์ผ ์†Œ์Šค๊ฐ€ ์•„๋‹ˆ๊ฒŒ ๋œ๋‹ค + // ์‚ฌ๋‹ค๋ฆฌ ๊ตฌ๊ฐ„์„ ๋งจ์ˆซ์ž๋กœ ์“ฐ๋ฉด tokens/base.css๊ฐ€ ๋‹จ์ผ ์†Œ์Šค๊ฐ€ ์•„๋‹ˆ๊ฒŒ ๋œ๋‹ค it('ํฌ๋กฌ ์‚ฌ๋‹ค๋ฆฌ ๊ตฌ๊ฐ„์„ ๋งจ์ˆซ์ž๋กœ ์“ฐ์ง€ ์•Š๋Š”๋‹ค', () => { const offenders = collectSources('src/renderer').filter((path) => { const source = stripComments(readSource(path)); diff --git a/src/renderer/assets/mp4/light/always-on-top.mp4 b/src/renderer/assets/mp4/light/always-on-top.mp4 new file mode 100644 index 000000000..2257d95bd Binary files /dev/null and b/src/renderer/assets/mp4/light/always-on-top.mp4 differ diff --git a/src/renderer/assets/mp4/light/custom-css.mp4 b/src/renderer/assets/mp4/light/custom-css.mp4 new file mode 100644 index 000000000..c901d3a6f Binary files /dev/null and b/src/renderer/assets/mp4/light/custom-css.mp4 differ diff --git a/src/renderer/assets/mp4/light/custom-js.mp4 b/src/renderer/assets/mp4/light/custom-js.mp4 new file mode 100644 index 000000000..3aa9c3eb5 Binary files /dev/null and b/src/renderer/assets/mp4/light/custom-js.mp4 differ diff --git a/src/renderer/assets/mp4/light/key-counter.mp4 b/src/renderer/assets/mp4/light/key-counter.mp4 new file mode 100644 index 000000000..7509e5131 Binary files /dev/null and b/src/renderer/assets/mp4/light/key-counter.mp4 differ diff --git a/src/renderer/assets/mp4/light/note-effect.mp4 b/src/renderer/assets/mp4/light/note-effect.mp4 new file mode 100644 index 000000000..5161aec70 Binary files /dev/null and b/src/renderer/assets/mp4/light/note-effect.mp4 differ diff --git a/src/renderer/assets/mp4/light/obs-mode.mp4 b/src/renderer/assets/mp4/light/obs-mode.mp4 new file mode 100644 index 000000000..b999dfff6 Binary files /dev/null and b/src/renderer/assets/mp4/light/obs-mode.mp4 differ diff --git a/src/renderer/assets/mp4/light/overlay-lock.mp4 b/src/renderer/assets/mp4/light/overlay-lock.mp4 new file mode 100644 index 000000000..14ea19821 Binary files /dev/null and b/src/renderer/assets/mp4/light/overlay-lock.mp4 differ diff --git a/src/renderer/assets/mp4/light/resize-anchor.mp4 b/src/renderer/assets/mp4/light/resize-anchor.mp4 new file mode 100644 index 000000000..9b388dffc Binary files /dev/null and b/src/renderer/assets/mp4/light/resize-anchor.mp4 differ diff --git a/src/renderer/assets/mp4/light/tray-enabled.mp4 b/src/renderer/assets/mp4/light/tray-enabled.mp4 new file mode 100644 index 000000000..a24eab349 Binary files /dev/null and b/src/renderer/assets/mp4/light/tray-enabled.mp4 differ diff --git a/src/renderer/components/main/Grid/PropertiesPanel.tsx b/src/renderer/components/main/Grid/PropertiesPanel.tsx index 816b43d5c..f176ca8ef 100644 --- a/src/renderer/components/main/Grid/PropertiesPanel.tsx +++ b/src/renderer/components/main/Grid/PropertiesPanel.tsx @@ -2430,7 +2430,7 @@ const PropertiesPanel: React.FC = ({ ); if (!sections.some((section) => section.renderVisible)) { return ( -

+

{t('propertiesPanel.pluginNoSettings') || '์„ค์ •ํ•  ํ•ญ๋ชฉ์ด ์—†์Šต๋‹ˆ๋‹ค.'}

); @@ -2602,7 +2602,7 @@ const PropertiesPanel: React.FC = ({ className="flex flex-col gap-[6px]" > {section.label && ( -

+

{sectionLabel}

)} diff --git a/src/renderer/components/main/Grid/PropertiesPanel/PluginSettingsPanelView.tsx b/src/renderer/components/main/Grid/PropertiesPanel/PluginSettingsPanelView.tsx index ab556f17e..04c9a6c69 100644 --- a/src/renderer/components/main/Grid/PropertiesPanel/PluginSettingsPanelView.tsx +++ b/src/renderer/components/main/Grid/PropertiesPanel/PluginSettingsPanelView.tsx @@ -73,7 +73,7 @@ const PluginSettingsPanelView: React.FC = ({ {t('propertiesPanel.pluginSettings') || 'ํ”Œ๋Ÿฌ๊ทธ์ธ ์„ค์ •'} {pluginSettingsPanel.pluginId} @@ -100,7 +100,7 @@ const PluginSettingsPanelView: React.FC = ({ ) : (
-

+

{t('propertiesPanel.pluginNoSettings') || '์„ค์ •ํ•  ํ•ญ๋ชฉ์ด ์—†์Šต๋‹ˆ๋‹ค.'}

diff --git a/src/renderer/components/main/Grid/PropertiesPanel/batch/BatchSelectionPanel.tsx b/src/renderer/components/main/Grid/PropertiesPanel/batch/BatchSelectionPanel.tsx index 2eb3e5dc7..3de5c7228 100644 --- a/src/renderer/components/main/Grid/PropertiesPanel/batch/BatchSelectionPanel.tsx +++ b/src/renderer/components/main/Grid/PropertiesPanel/batch/BatchSelectionPanel.tsx @@ -372,7 +372,7 @@ const BatchPanelHeader: React.FC = ({
)} {!selectedGroupInfo && ( - ({totalCount}) + ({totalCount}) )} diff --git a/src/renderer/components/main/Grid/PropertiesPanel/layer/LayerTabContent.tsx b/src/renderer/components/main/Grid/PropertiesPanel/layer/LayerTabContent.tsx index b1fcfe7c0..8cc8701bf 100644 --- a/src/renderer/components/main/Grid/PropertiesPanel/layer/LayerTabContent.tsx +++ b/src/renderer/components/main/Grid/PropertiesPanel/layer/LayerTabContent.tsx @@ -638,7 +638,7 @@ const LayerTabContent: React.FC = ({ > {layerItems.length === 0 ? (
-

+

{t('propertiesPanel.noLayers') || 'No layers'}

diff --git a/src/renderer/components/main/Grid/PropertiesPanel/panelChrome.test.ts b/src/renderer/components/main/Grid/PropertiesPanel/panelChrome.test.ts index 301472a9d..69459d177 100644 --- a/src/renderer/components/main/Grid/PropertiesPanel/panelChrome.test.ts +++ b/src/renderer/components/main/Grid/PropertiesPanel/panelChrome.test.ts @@ -1,5 +1,6 @@ import { describe, expect, it } from 'vitest'; +import { MINIMAP_SURFACE_CLASS } from '@components/main/Grid/core/minimapChrome'; import { PANEL_HEADER_CLASS, PANEL_HEADER_HEIGHT, @@ -23,7 +24,14 @@ describe('panelChrome', () => { // ์นฉ์€ opacity๋กœ ๋“ฑํ‡ด์žฅํ•œ๋‹ค - ๋ผ์ด๋ธŒ ๋ธ”๋Ÿฌ๊ฐ€ ๋ถ™์œผ๋ฉด WKWebView์—์„œ ์ ๋ฉธ it('์ ‘ํž˜ ํ† ๊ธ€ ์นฉ์€ ๋ผ์ด๋ธŒ ๋ธ”๋Ÿฌ ์—†๋Š” ์†”๋ฆฌ๋“œ ์žฌ์งˆ์ด๋‹ค', () => { - expect(SIDE_PANEL_CHIP_MATERIAL_CLASS).toContain('bg-glass-panel-solid'); + expect(SIDE_PANEL_CHIP_MATERIAL_CLASS).toContain('-solid'); expect(SIDE_PANEL_CHIP_MATERIAL_CLASS).not.toContain('backdrop-glass'); }); + + // ์นฉ์ด ๋ณด์ด๋Š” ๊ฑด ํŒจ๋„์ด ๋‹ซํ˜€ ์žˆ์„ ๋•Œ๋ฟ์ด๊ณ , ๊ทธ๋•Œ๋Š” ๋ฏธ๋‹ˆ๋งต๊ณผ ๊ฐ™์€ ํ‹ฐ์–ด์˜ + // ์บ”๋ฒ„์Šค ๋ถ€์œ  ์นฉ์ด๋‹ค. ํŒจ๋„ ๊ณ„์—ด ์žฌ์งˆ๋กœ ๋˜๋Œ๋ฆฌ๋ฉด ๋ผ์ดํŠธ์—์„œ ๋‘ ์นฉ์ด 8 L* ๋ฒŒ์–ด์ง„๋‹ค + it('์ ‘ํž˜ ํ† ๊ธ€ ์นฉ์€ ๋ฏธ๋‹ˆ๋งต๊ณผ ๊ฐ™์€ ์บ”๋ฒ„์Šค ํฌ๋กฌ ๊ณ„์—ด์„ ์“ด๋‹ค', () => { + expect(SIDE_PANEL_CHIP_MATERIAL_CLASS).toContain('bg-glass-dim'); + expect(MINIMAP_SURFACE_CLASS).toContain('bg-glass-dim'); + }); }); diff --git a/src/renderer/components/main/Grid/PropertiesPanel/panelChrome.ts b/src/renderer/components/main/Grid/PropertiesPanel/panelChrome.ts index f679aea55..471349803 100644 --- a/src/renderer/components/main/Grid/PropertiesPanel/panelChrome.ts +++ b/src/renderer/components/main/Grid/PropertiesPanel/panelChrome.ts @@ -9,8 +9,12 @@ export const SIDE_PANEL_MATERIAL_CLASS = // ์ ‘ํž˜ ํ† ๊ธ€ ์นฉ - opacity๋กœ ๋“ฑํ‡ด์žฅํ•˜๋ฏ€๋กœ ๋ผ์ด๋ธŒ ๋ธ”๋Ÿฌ ๊ธˆ์ง€ // (๋ธ”๋Ÿฌ+opacity ์กฐํ•ฉ์€ WKWebView์—์„œ ๋ธ”๋Ÿฌ ๋ ˆ์ด์–ด๊ฐ€ ์ ๋ฉธํ•˜๊ณ , ํŽ˜์ด๋“œ๋ฅผ ๋ž˜ํผ๋กœ // ์˜ฎ๊ฒจ๋„ opacity < 1์ธ ์กฐ์ƒ์ด backdrop root๊ฐ€ ๋˜์–ด ๊ฐ™์€ ์ฆ์ƒ - Modal.tsx ์ฐธ์กฐ). -// ํ”„๋ ˆ์ž„๊ณผ ๊ฐ™์€ L*์— ์„œ๋„๋ก ์ƒ์ฃผ ํฌ๋กฌ ์†”๋ฆฌ๋“œ ํ† ํฐ์„ ์“ด๋‹ค -export const SIDE_PANEL_CHIP_MATERIAL_CLASS = 'bg-glass-panel-solid'; +// ์žฌ์งˆ์€ ํŒจ๋„ ๊ณ„์—ด์ด ์•„๋‹ˆ๋ผ ์บ”๋ฒ„์Šค ์ƒ์ฃผ ํฌ๋กฌ์ด๋‹ค - ์นฉ์ด ๋ณด์ด๋Š” ๊ฑด ํŒจ๋„์ด ๋‹ซํ˜€ ์žˆ์„ +// ๋•Œ๋ฟ์ด๊ณ , ๊ทธ๋•Œ๋Š” ๋ฏธ๋‹ˆ๋งต๊ณผ ๊ฐ™์€ ํ‹ฐ์–ด์˜ ๋ถ€์œ  ์นฉ์ด๋ผ ๊ฐ™์€ ๋ฉด์œผ๋กœ ์ฝํ˜€์•ผ ํ•œ๋‹ค. +// ์—ด๋ฆด ๋•Œ ํ”„๋ ˆ์ž„๊ณผ L*๊ฐ€ ์–ด๊ธ‹๋‚˜๋Š” ๊ตฌ๊ฐ„์ด ์ƒ๊ธฐ์ง€๋งŒ ๊ทธ๊ฑด ํŽ˜์ด๋“œ 200ms ์•ˆ์ด๊ณ , +// ์ƒ์‹œ ๋ณด์ด๋Š” ์ƒํƒœ๊ฐ€ ์šฐ์„ ํ•œ๋‹ค. +// -solid๋Š” ๋ผ์ด๋ธŒ dim๊ณผ ๊ฐ™์€ L*์— ์„œ๋„๋ก ๋งž์ถฐ ๋‘” ์†”๋ฆฌ๋“œ ์Œ๋‘ฅ์ด๋‹ค +export const SIDE_PANEL_CHIP_MATERIAL_CLASS = 'bg-glass-dim-solid'; export const SIDE_PANEL_FRAME_CLASS = `absolute right-0 top-0 bottom-0 w-[240px] ${SIDE_PANEL_MATERIAL_CLASS} shadow-elevation-panel z-[var(--z-chrome-panel)]`; diff --git a/src/renderer/components/main/Grid/PropertiesPanel/single/SingleSelectionPanel.tsx b/src/renderer/components/main/Grid/PropertiesPanel/single/SingleSelectionPanel.tsx index a889aa2ae..bf438071a 100644 --- a/src/renderer/components/main/Grid/PropertiesPanel/single/SingleSelectionPanel.tsx +++ b/src/renderer/components/main/Grid/PropertiesPanel/single/SingleSelectionPanel.tsx @@ -304,7 +304,7 @@ export const PluginSelectionPanel: React.FC = ({
-

{noticeText}

+

{noticeText}

); @@ -341,13 +341,13 @@ export const PluginSelectionPanel: React.FC = ({ /> )} {!hasSinglePluginSelection && ( -

+

{t('propertiesPanel.pluginMultiSelection') || 'ํ”Œ๋Ÿฌ๊ทธ์ธ ์š”์†Œ๋Š” ํ•œ ๋ฒˆ์— ํ•˜๋‚˜๋งŒ ํŽธ์ง‘ํ•  ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค.'}

)} {hasSinglePluginSelection && showModalHint && ( -

+

{t('propertiesPanel.pluginModalHint') || '์ด ํ”Œ๋Ÿฌ๊ทธ์ธ์€ ์„ค์ • ๋ชจ๋‹ฌ์„ ์‚ฌ์šฉํ•ฉ๋‹ˆ๋‹ค. ์š”์†Œ๋ฅผ ํด๋ฆญํ•ด ์„ค์ •ํ•˜์„ธ์š”.'}

diff --git a/src/renderer/components/main/Grid/PropertiesPanel/single/StyleTabContent.tsx b/src/renderer/components/main/Grid/PropertiesPanel/single/StyleTabContent.tsx index f9fed9ef2..ee6d0647e 100644 --- a/src/renderer/components/main/Grid/PropertiesPanel/single/StyleTabContent.tsx +++ b/src/renderer/components/main/Grid/PropertiesPanel/single/StyleTabContent.tsx @@ -726,7 +726,7 @@ const StyleTabContent: React.FC = ({ {!(slotListening && !slotPickerOpen) && slotParts.extra && ( // case ํ”ผ์ฒ˜: +๋ฅผ ์ˆซ์ž ์ค‘์‹ฌ์— ๋งž์ถ˜ ๊ธ€๋ฆฌํ”„๋กœ ์น˜ํ™˜. // tracking์€ +์™€ ์ˆซ์ž ์‚ฌ์ด 0.25px ํ™•๋ณด์šฉ, ๋ ๊ธ€์ž ๋’ค ์—ฌ๋ถ„์€ -mr๋กœ ์ƒ์‡„ (๋ฐฐ์ง€๋Š” ํ•œ ์ž๋ฆฌ ์ „์ œ) - + {slotParts.extra} )} diff --git a/src/renderer/components/main/Grid/PropertiesPanelHost.tsx b/src/renderer/components/main/Grid/PropertiesPanelHost.tsx index 8d6a41ee6..0aca286df 100644 --- a/src/renderer/components/main/Grid/PropertiesPanelHost.tsx +++ b/src/renderer/components/main/Grid/PropertiesPanelHost.tsx @@ -7,6 +7,7 @@ import { useBlockBrowserShortcuts } from '@hooks/app/useBlockBrowserShortcuts'; import { usePanelHeaderDrag } from '@hooks/panel/usePanelHeaderDrag'; import { restoreLenisScroll } from '@hooks/useLenis'; import { panelWindowApi } from '@api/modules/panelWindowApi'; +import { subscribeResolvedTheme } from '@utils/theme/applyTheme'; import { flushFocusedEditor } from '@src/renderer/editor/runtime/lifecycleEditorFlush'; import { isHistoryEditorFlushLocked } from '@src/renderer/editor/runtime/historyEditorFlushLock'; import { @@ -392,8 +393,15 @@ const PropertiesPanelHost = ({ subtree: true, characterData: true, }); + // ํ…Œ๋งˆ ์ „ํ™˜์€ html ์†์„ฑ๋งŒ ๋ฐ”๊พผ๋‹ค - head ์˜ต์ €๋ฒ„์— ์•ˆ ๊ฑธ๋ ค ๋„ค์ดํ‹ฐ๋ธŒ ๊ฐ€์žฅ์ž๋ฆฌ๊ฐ€ + // ์˜› ์ƒ‰์œผ๋กœ ๊ณ ์ฐฉ๋œ๋‹ค. ์†์„ฑ์ด ๋ฐ”๋€ ๋’ค ํ† ํฐ์ด ์ƒˆ ๊ฐ’์ด๋ฏ€๋กœ ๋‹ค์Œ ํ”„๋ ˆ์ž„์— ์ฝ๋Š”๋‹ค + const unsubscribeTheme = subscribeResolvedTheme(() => { + cancelAnimationFrame(scheduled); + scheduled = requestAnimationFrame(sync); + }); return () => { observer.disconnect(); + unsubscribeTheme(); cancelAnimationFrame(scheduled); }; }, [childWindow]); @@ -405,7 +413,7 @@ const PropertiesPanelHost = ({ {dockHint && (