From a9188016f45ab7c81fa2ccd6ae92f8ca74a25dcc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=97=B0=EC=9A=B0?= Date: Sun, 19 Jul 2026 22:51:00 +0900 Subject: [PATCH 1/6] =?UTF-8?q?docs:=20=EC=9D=B8=EB=9D=BC=EC=9D=B8=20?= =?UTF-8?q?=EC=9A=B0=EC=84=A0=20=EB=AA=A8=EB=93=9C=20=EB=8D=AE=EC=96=B4?= =?UTF-8?q?=EC=93=B0=EA=B8=B0=20=EC=98=88=EC=8B=9C=EB=A5=BC=20=EC=A7=81?= =?UTF-8?q?=EC=A0=91=20CSS=20=EC=86=8D=EC=84=B1=20!important=20=EA=B8=B0?= =?UTF-8?q?=EC=A4=80=EC=9C=BC=EB=A1=9C=20=EA=B5=90=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/content/en/custom-css/variables/page.mdx | 154 +++++++++++------ docs/content/ko/custom-css/variables/page.mdx | 155 ++++++++++++------ .../customCssPriorityContract.test.ts | 142 ++++++++++++++++ 3 files changed, 354 insertions(+), 97 deletions(-) create mode 100644 src/renderer/__tests__/customCssPriorityContract.test.ts diff --git a/docs/content/en/custom-css/variables/page.mdx b/docs/content/en/custom-css/variables/page.mdx index b04091d3..1c2175e7 100644 --- a/docs/content/en/custom-css/variables/page.mdx +++ b/docs/content/en/custom-css/variables/page.mdx @@ -11,35 +11,39 @@ This page lists all CSS variables available for styling in DM Note. CSS variables applied to key elements. Use with `[data-state="inactive"]` or `[data-state="active"]` selectors. -| Variable | Description | Type | Example | -| ------------------ | ------------------------ | ---------- | ------------------------ | -| `--key-radius` | Corner roundness | `` | `8px`, `50%` | -| `--key-bg` | Background color | `` | `#ff2b80` | -| `--key-bg-image` | Background image (gradient) | `` | `linear-gradient(90deg, #f0f, #0ff)` | -| `--key-border` | Border style | `` | `2px solid #fff`, `none` | -| `--key-text-color` | Text color | `` | `#ffffff` | -| `--key-offset-x` | X offset in active state | `` | `0px`, `2px` | -| `--key-offset-y` | Y offset in active state | `` | `4px` | +| Variable | Description | Type | Example | +| --------------------- | -------------------------------------- | ---------- | ------------------------------------ | +| `--key-radius` | Corner roundness | `` | `8px`, `50%` | +| `--key-bg` | Background color and saved-image reset | `` | `#ff2b80` | +| `--key-bg-image` | Background image | `` | `linear-gradient(90deg, #f0f, #0ff)` | +| `--key-border` | Border and saved border-ring reset | `` | `2px solid #fff`, `none` | +| `--key-border-image` | Replacement image for a saved ring | `` | `linear-gradient(90deg, #f0f, #0ff)` | +| `--key-padding` | Inner padding | `` | `0px`, `3px` | +| `--key-text-color` | Text color | `` | `#ffffff` | +| `--key-shadow` | Idle and shared fallback shadow | `` | `0 4px 10px rgba(0,0,0,.28)`, `none` | +| `--key-active-shadow` | Active shadow | `` | `0 3px 8px rgba(0,0,0,.32)`, `none` | +| `--key-offset-x` | X offset in active state | `` | `0px`, `2px` | +| `--key-offset-y` | Y offset in active state | `` | `4px` | - `--key-bg` is applied as `background-color`. To use gradients, specify - `background` or `background-image` directly and set `--key-bg: transparent;` - if needed. When a key color is set to a gradient in the app, it renders as - `background-image: var(--key-bg-image, ...)`, so you can override it with - `--key-bg-image` (solid keys never get an inline `background-image`). + With **Inline Styles Priority** disabled, property-panel colors, gradients, + borders, typography, and shadows are low-priority defaults only. Setting + `--key-bg` automatically removes a saved background gradient. Setting + `--key-border` removes the saved gradient border ring and its spacing. Use + `--key-bg-image` or the standard `background` property for a custom gradient. ### Usage Example ```css -[data-state="inactive"] { +[data-state='inactive'] { --key-radius: 8px; --key-bg: #2a2a2a; --key-border: 2px solid #444; --key-text-color: #888; } -[data-state="active"] { +[data-state='active'] { --key-radius: 8px; --key-bg: #ff2b80; --key-border: 2px solid #ff2b80; @@ -53,22 +57,36 @@ CSS variables applied to key elements. Use with `[data-state="inactive"]` or `[d CSS variables applied to counter elements. Use with `.counter[data-counter-state="..."]` selectors. -| Variable | Description | Type | Example | -| ------------------------ | ---------------------- | ---------- | ------------------------ | -| `--counter-color` | Counter text color | `` | `#ffffff` | -| `--counter-stroke-color` | Text outline color | `` | `#000000`, `transparent` | -| `--counter-stroke-width` | Text outline thickness | `` | `1px`, `2px` | +| Variable | Description | Type | Example | +| --------------------------- | ------------------------------- | ---------- | ------------------------------------ | +| `--counter-color` | Text color and saved-fill reset | `` | `#ffffff` | +| `--counter-fill-image` | Text fill image | `` | `linear-gradient(90deg, #f0f, #0ff)` | +| `--counter-fill-clip` | Fill clipping area | `` | `text`, `border-box` | +| `--counter-text-fill-color` | WebKit text fill color | `` | `transparent`, `currentcolor` | +| `--counter-stroke-color` | Text outline color | `` | `#000000`, `transparent` | +| `--counter-stroke-width` | Text outline thickness | `` | `1px`, `2px` | + +Setting only `--counter-color` also disables a counter fill gradient saved in +the app. To create a CSS fill gradient, set all three fill variables: + +```css +.counter { + --counter-fill-image: linear-gradient(90deg, #f0f, #0ff); + --counter-fill-clip: text; + --counter-text-fill-color: transparent; +} +``` ### Usage Example ```css -.counter[data-counter-state="inactive"] { +.counter[data-counter-state='inactive'] { --counter-color: #666; --counter-stroke-color: transparent; --counter-stroke-width: 0px; } -.counter[data-counter-state="active"] { +.counter[data-counter-state='active'] { --counter-color: #ff2b80; --counter-stroke-color: #fff; --counter-stroke-width: 1px; @@ -80,12 +98,14 @@ CSS variables applied to counter elements. Use with `.counter[data-counter-state CSS variables available for graph elements. Disable **Inline Styles Priority** on the graph to control these via CSS. -| Variable | Description | Type | Example | -| ---------------- | ---------------------- | ---------- | ------------------------ | -| `--graph-bg` | Graph background | `` | `rgba(17, 17, 20, 0.85)` | -| `--graph-border` | Graph border | `` | `1px solid #7dd3fc` | -| `--graph-radius` | Graph corner radius | `` | `10px` | -| `--graph-color` | Line/bar drawing color | `` | `#86efac` | +| Variable | Description | Type | Example | +| ---------------------- | ---------------------------------- | ---------- | ------------------------------------ | +| `--graph-bg` | Graph background | `` | `rgba(17, 17, 20, 0.85)` | +| `--graph-border` | Border and saved border-ring reset | `` | `1px solid #7dd3fc`, `none` | +| `--graph-border-image` | Replacement image for a saved ring | `` | `linear-gradient(90deg, #f0f, #0ff)` | +| `--graph-padding` | Graph inner padding | `` | `0px`, `3px` | +| `--graph-radius` | Graph corner radius | `` | `10px` | +| `--graph-color` | Line/bar drawing color | `` | `#86efac` | ### Usage Example @@ -98,6 +118,31 @@ Disable **Inline Styles Priority** on the graph to control these via CSS. } ``` +## Knob Style Variables + +Disable **Inline Styles Priority** on a knob to use these variables. The custom +class is on the position wrapper, so target `.knob-class [data-knob-element]` +when applying standard properties directly to the visible surface. + +| Variable | Description | Type | Example | +| ---------------------- | ---------------------------------- | ---------- | ------------------------------------ | +| `--knob-bg` | Knob background | `` | `#222` | +| `--knob-border` | Border and saved border-ring reset | `` | `2px solid #fff`, `none` | +| `--knob-border-image` | Replacement image for a saved ring | `` | `linear-gradient(90deg, #f0f, #0ff)` | +| `--knob-padding` | Knob inner padding | `` | `0px`, `3px` | +| `--knob-radius` | Corner radius | `` | `50%`, `8px` | +| `--knob-shadow` | Knob shadow | `` | `0 4px 10px rgba(0,0,0,.3)` | +| `--knob-active-shadow` | Turning-state shadow | `` | `0 3px 8px rgba(0,0,0,.32)` | +| `--knob-indicator` | Center rotation indicator color | `` | `#fff` | + +```css +.volume-knob { + --knob-bg: #17171b; + --knob-border: none; + --knob-indicator: #ff2b80; +} +``` + ## Selector Reference ### Key Selectors @@ -120,21 +165,33 @@ Disable **Inline Styles Priority** on the graph to control these via CSS. ### Graph Selectors -| Selector | Description | -| ------------------------- | ------------------------------------------------ | -| `.graphClassName` | Style a specific graph container | -| `.graphClassName svg` | Style line graph SVG (stroke/fill) | -| `.graphClassName > div` | Style the inner plot area (line/bar shared) | -| `.graphClassName > div > div` | Style individual bars in bar mode | +| Selector | Description | +| ----------------------------- | ------------------------------------------- | +| `.graphClassName` | Style a specific graph container | +| `.graphClassName svg` | Style line graph SVG (stroke/fill) | +| `.graphClassName > div` | Style the inner plot area (line/bar shared) | +| `.graphClassName > div > div` | Style individual bars in bar mode | + +### Knob Selectors + +| Selector | Description | +| -------------------------------- | ------------------------------- | +| `[data-knob-element]` | Every visible knob surface | +| `[data-knob-state="inactive"]` | Idle knob surfaces | +| `[data-knob-state="active"]` | Active knob surfaces | +| `.knobClass [data-knob-element]` | A specific class's knob surface | +| `[data-knob-indicator]` | Center rotation indicator | ## Standard CSS Properties -In addition to CSS variables, you can freely use the following standard CSS properties: +With **Inline Styles Priority** disabled, you can directly use standard visual +properties such as the following. Element positioning remains app-managed; use +`--key-offset-x`/`--key-offset-y` to move keys, graphs, and knobs. ### Commonly Used Properties ```css -[data-state="active"] { +[data-state='active'] { /* Shadow effect */ box-shadow: 0px 0px 8px #ff2b80; @@ -147,10 +204,7 @@ In addition to CSS variables, you can freely use the following standard CSS prop /* Font style */ font-size: 24px; font-weight: 700; - font-family: "Roboto", sans-serif; - - /* Transform */ - transform: scale(1.05); + font-family: 'Roboto', sans-serif; /* Filter */ filter: brightness(1.2); @@ -161,18 +215,22 @@ In addition to CSS variables, you can freely use the following standard CSS prop ### Using !important -Use `!important` if some styles are not being applied: +Normal declarations lose to inline values only when **Inline Styles Priority** +is enabled. In that mode, override the rendered CSS property itself with +`!important`; adding it to a fallback custom property cannot beat an inline +property declaration: ```css -[data-state="active"] { - --key-bg: #ff2b80 !important; +[data-state='active'] { + background: #ff2b80 !important; box-shadow: 0px 0px 8px #ff2b80 !important; } ``` ### Browser Compatibility -DM Note runs on Chromium-based WebView2, supporting most modern CSS features: +DM Note uses WebView2 on Windows and WKWebView on macOS. You can use modern CSS +features supported by the WebView on the current operating system: - CSS Variables (Custom Properties) ✅ - CSS Grid / Flexbox ✅ @@ -193,17 +251,17 @@ DM Note runs on Chromium-based WebView2, supporting most modern CSS features: ```css /* Priority: 1 (lowest) */ -[data-state="active"] { +[data-state='active'] { --key-bg: red; } /* Priority: 2 */ -.blue[data-state="active"] { +.blue[data-state='active'] { --key-bg: blue; } /* Priority: 3 (highest) */ -.blue.special[data-state="active"] { +.blue.special[data-state='active'] { --key-bg: purple; } ``` diff --git a/docs/content/ko/custom-css/variables/page.mdx b/docs/content/ko/custom-css/variables/page.mdx index 2bc30022..bc6d8129 100644 --- a/docs/content/ko/custom-css/variables/page.mdx +++ b/docs/content/ko/custom-css/variables/page.mdx @@ -11,35 +11,39 @@ DM Note에서 스타일링에 사용할 수 있는 모든 CSS 변수를 정리 키 요소에 적용되는 CSS 변수입니다. `[data-state="inactive"]` 또는 `[data-state="active"]` 선택자와 함께 사용합니다. -| 변수 | 설명 | 타입 | 예시 | -| ------------------ | ------------------------ | ---------- | ------------------------ | -| `--key-radius` | 키 모서리의 둥근 정도 | `` | `8px`, `50%` | -| `--key-bg` | 키의 배경색 | `` | `#ff2b80` | -| `--key-bg-image` | 키의 배경 이미지(그라데이션) | `` | `linear-gradient(90deg, #f0f, #0ff)` | -| `--key-border` | 키의 테두리 스타일 | `` | `2px solid #fff`, `none` | -| `--key-text-color` | 키 텍스트 색상 | `` | `#ffffff` | -| `--key-offset-x` | 활성 상태에서 X축 이동량 | `` | `0px`, `2px` | -| `--key-offset-y` | 활성 상태에서 Y축 이동량 | `` | `4px` | +| 변수 | 설명 | 타입 | 예시 | +| --------------------- | ----------------------------------- | ---------- | ------------------------------------ | +| `--key-radius` | 키 모서리의 둥근 정도 | `` | `8px`, `50%` | +| `--key-bg` | 키 배경색과 저장된 배경 이미지 해제 | `` | `#ff2b80` | +| `--key-bg-image` | 키의 배경 이미지 | `` | `linear-gradient(90deg, #f0f, #0ff)` | +| `--key-border` | 키 테두리와 저장된 보더 링 해제 | `` | `2px solid #fff`, `none` | +| `--key-border-image` | 저장된 보더 링의 이미지 교체 | `` | `linear-gradient(90deg, #f0f, #0ff)` | +| `--key-padding` | 키 내부 여백 | `` | `0px`, `3px` | +| `--key-text-color` | 키 텍스트 색상 | `` | `#ffffff` | +| `--key-shadow` | 대기 및 공통 폴백 그림자 | `` | `0 4px 10px rgba(0,0,0,.28)`, `none` | +| `--key-active-shadow` | 입력 상태 그림자 | `` | `0 3px 8px rgba(0,0,0,.32)`, `none` | +| `--key-offset-x` | 활성 상태에서 X축 이동량 | `` | `0px`, `2px` | +| `--key-offset-y` | 활성 상태에서 Y축 이동량 | `` | `4px` | - `--key-bg`는 `background-color`로 적용됩니다. 그라데이션을 사용하려면 - `background` 또는 `background-image`를 직접 지정하고, 필요하면 `--key-bg: - transparent;`로 설정하세요. 앱에서 키 색상을 그라데이션으로 설정한 경우에는 - `background-image: var(--key-bg-image, ...)`로 렌더되므로 `--key-bg-image`로 - 덮어쓸 수 있습니다 (단색 키에는 인라인 `background-image`가 붙지 않습니다). + **인라인 스타일 우선**이 꺼져 있으면 속성 패널의 색·그라데이션·보더·글꼴· + 그림자는 낮은 우선순위의 기본값으로만 적용됩니다. `--key-bg`를 지정하면 저장된 + 배경 그라데이션이 자동으로 사라지고, `--key-border`를 지정하면 저장된 + 그라데이션 보더 링과 링 여백이 함께 사라집니다. 사용자 그라데이션은 + `--key-bg-image`나 표준 `background` 속성으로 지정할 수 있습니다. ### 사용 예시 ```css -[data-state="inactive"] { +[data-state='inactive'] { --key-radius: 8px; --key-bg: #2a2a2a; --key-border: 2px solid #444; --key-text-color: #888; } -[data-state="active"] { +[data-state='active'] { --key-radius: 8px; --key-bg: #ff2b80; --key-border: 2px solid #ff2b80; @@ -53,22 +57,36 @@ DM Note에서 스타일링에 사용할 수 있는 모든 CSS 변수를 정리 카운터 요소에 적용되는 CSS 변수입니다. `.counter[data-counter-state="..."]` 선택자와 함께 사용합니다. -| 변수 | 설명 | 타입 | 예시 | -| ------------------------ | ------------------ | ---------- | ------------------------ | -| `--counter-color` | 카운터 텍스트 색상 | `` | `#ffffff` | -| `--counter-stroke-color` | 텍스트 외곽선 색상 | `` | `#000000`, `transparent` | -| `--counter-stroke-width` | 텍스트 외곽선 두께 | `` | `1px`, `2px` | +| 변수 | 설명 | 타입 | 예시 | +| --------------------------- | ------------------------------ | ---------- | ------------------------------------ | +| `--counter-color` | 텍스트 색상과 저장된 fill 해제 | `` | `#ffffff` | +| `--counter-fill-image` | 텍스트 fill 이미지 | `` | `linear-gradient(90deg, #f0f, #0ff)` | +| `--counter-fill-clip` | fill 클립 영역 | `` | `text`, `border-box` | +| `--counter-text-fill-color` | WebKit 텍스트 채움색 | `` | `transparent`, `currentcolor` | +| `--counter-stroke-color` | 텍스트 외곽선 색상 | `` | `#000000`, `transparent` | +| `--counter-stroke-width` | 텍스트 외곽선 두께 | `` | `1px`, `2px` | + +`--counter-color`만 지정해도 앱에 저장된 카운터 fill 그라데이션은 자동으로 +해제됩니다. CSS로 새 fill 그라데이션을 만들 때는 다음 세 변수를 함께 사용합니다. + +```css +.counter { + --counter-fill-image: linear-gradient(90deg, #f0f, #0ff); + --counter-fill-clip: text; + --counter-text-fill-color: transparent; +} +``` ### 사용 예시 ```css -.counter[data-counter-state="inactive"] { +.counter[data-counter-state='inactive'] { --counter-color: #666; --counter-stroke-color: transparent; --counter-stroke-width: 0px; } -.counter[data-counter-state="active"] { +.counter[data-counter-state='active'] { --counter-color: #ff2b80; --counter-stroke-color: #fff; --counter-stroke-width: 1px; @@ -80,12 +98,14 @@ DM Note에서 스타일링에 사용할 수 있는 모든 CSS 변수를 정리 그래프 요소에 적용되는 CSS 변수입니다. 그래프의 **인라인 스타일 우선**을 끄면 아래 변수를 통해 외형을 제어할 수 있습니다. -| 변수 | 설명 | 타입 | 예시 | -| ---------------- | ------------- | ---------- | ------------------------ | -| `--graph-bg` | 그래프 배경 | `` | `rgba(17, 17, 20, 0.85)` | -| `--graph-border` | 그래프 테두리 | `` | `1px solid #7dd3fc` | -| `--graph-radius` | 그래프 라운딩 | `` | `10px` | -| `--graph-color` | 라인/바 색상 | `` | `#86efac` | +| 변수 | 설명 | 타입 | 예시 | +| ---------------------- | ----------------------------------- | ---------- | ------------------------------------ | +| `--graph-bg` | 그래프 배경 | `` | `rgba(17, 17, 20, 0.85)` | +| `--graph-border` | 그래프 테두리와 저장된 보더 링 해제 | `` | `1px solid #7dd3fc`, `none` | +| `--graph-border-image` | 저장된 보더 링의 이미지 교체 | `` | `linear-gradient(90deg, #f0f, #0ff)` | +| `--graph-padding` | 그래프 내부 여백 | `` | `0px`, `3px` | +| `--graph-radius` | 그래프 라운딩 | `` | `10px` | +| `--graph-color` | 라인/바 색상 | `` | `#86efac` | ### 사용 예시 @@ -98,6 +118,31 @@ DM Note에서 스타일링에 사용할 수 있는 모든 CSS 변수를 정리 } ``` +## 노브 스타일 변수 + +노브의 **인라인 스타일 우선**을 끄면 아래 변수를 사용할 수 있습니다. 노브에 +지정한 클래스는 위치 래퍼에 있으므로 일반 속성을 직접 지정할 때는 +`.노브클래스 [data-knob-element]` 선택자를 사용하세요. + +| 변수 | 설명 | 타입 | 예시 | +| ---------------------- | --------------------------------- | ---------- | ------------------------------------ | +| `--knob-bg` | 노브 배경 | `` | `#222` | +| `--knob-border` | 노브 테두리와 저장된 보더 링 해제 | `` | `2px solid #fff`, `none` | +| `--knob-border-image` | 저장된 보더 링의 이미지 교체 | `` | `linear-gradient(90deg, #f0f, #0ff)` | +| `--knob-padding` | 노브 내부 여백 | `` | `0px`, `3px` | +| `--knob-radius` | 노브 모서리 | `` | `50%`, `8px` | +| `--knob-shadow` | 노브 그림자 | `` | `0 4px 10px rgba(0,0,0,.3)` | +| `--knob-active-shadow` | 회전 중 그림자 | `` | `0 3px 8px rgba(0,0,0,.32)` | +| `--knob-indicator` | 중앙 회전 표시 색상 | `` | `#fff` | + +```css +.volume-knob { + --knob-bg: #17171b; + --knob-border: none; + --knob-indicator: #ff2b80; +} +``` + ## 선택자 레퍼런스 ### 키 선택자 @@ -120,21 +165,33 @@ DM Note에서 스타일링에 사용할 수 있는 모든 CSS 변수를 정리 ### 그래프 선택자 -| 선택자 | 설명 | -| ----------------------- | ------------------------------------- | -| `.그래프클래스명` | 특정 그래프 컨테이너 스타일링 | -| `.그래프클래스명 svg` | 라인 그래프 SVG(선/채움) 스타일링 | -| `.그래프클래스명 > div` | 그래프 내부 플롯 영역(라인/바 공통) | +| 선택자 | 설명 | +| ----------------------------- | --------------------------------------- | +| `.그래프클래스명` | 특정 그래프 컨테이너 스타일링 | +| `.그래프클래스명 svg` | 라인 그래프 SVG(선/채움) 스타일링 | +| `.그래프클래스명 > div` | 그래프 내부 플롯 영역(라인/바 공통) | | `.그래프클래스명 > div > div` | 바 그래프 막대 개별 스타일링 (bar 모드) | +### 노브 선택자 + +| 선택자 | 설명 | +| --------------------------------- | ------------------------ | +| `[data-knob-element]` | 모든 노브 표면 | +| `[data-knob-state="inactive"]` | 입력 대기 중인 노브 표면 | +| `[data-knob-state="active"]` | 입력 중인 노브 표면 | +| `.노브클래스 [data-knob-element]` | 특정 클래스의 노브 표면 | +| `[data-knob-indicator]` | 노브 중앙 회전 표시 | + ## 표준 CSS 속성 -CSS 변수 외에도 다음 표준 CSS 속성들을 자유롭게 사용할 수 있습니다: +**인라인 스타일 우선**이 꺼진 요소에는 다음과 같은 표준 외형 속성을 직접 사용할 +수 있습니다. 위치는 앱이 관리하므로 키·그래프·노브 이동에는 +`--key-offset-x`/`--key-offset-y`를 사용하세요. ### 자주 사용되는 속성 ```css -[data-state="active"] { +[data-state='active'] { /* 그림자 효과 */ box-shadow: 0px 0px 8px #ff2b80; @@ -147,10 +204,7 @@ CSS 변수 외에도 다음 표준 CSS 속성들을 자유롭게 사용할 수 /* 폰트 스타일 */ font-size: 24px; font-weight: 700; - font-family: "Roboto", sans-serif; - - /* 변형 */ - transform: scale(1.05); + font-family: 'Roboto', sans-serif; /* 필터 */ filter: brightness(1.2); @@ -161,18 +215,21 @@ CSS 변수 외에도 다음 표준 CSS 속성들을 자유롭게 사용할 수 ### !important 사용 -일부 스타일이 적용되지 않는 경우 `!important`를 사용할 수 있습니다: +요소의 **인라인 스타일 우선**을 켠 경우에만 일반 선언보다 인라인 값이 우선합니다. +이 모드까지 강제로 덮어쓸 때는 실제 렌더 CSS 속성 자체에 `!important`를 사용해야 +합니다. 폴백 커스텀 속성에 붙여도 인라인 속성 선언보다 우선할 수 없습니다: ```css -[data-state="active"] { - --key-bg: #ff2b80 !important; +[data-state='active'] { + background: #ff2b80 !important; box-shadow: 0px 0px 8px #ff2b80 !important; } ``` ### 브라우저 호환성 -DM Note는 Chromium 기반 WebView2에서 동작하므로 최신 CSS 기능을 대부분 지원합니다: +DM Note는 Windows의 WebView2와 macOS의 WKWebView에서 동작합니다. 운영체제별 +WebView가 지원하는 최신 CSS 기능을 사용할 수 있습니다: - CSS Variables (Custom Properties) ✅ - CSS Grid / Flexbox ✅ @@ -182,8 +239,8 @@ DM Note는 Chromium 기반 WebView2에서 동작하므로 최신 CSS 기능을 - mix-blend-mode ✅ - 단, 오버레이의 배경이 투명(`transparent`) 상태일 때는 `backdrop-filter`가 동작하지 - 않습니다. + 단, 오버레이의 배경이 투명(`transparent`) 상태일 때는 `backdrop-filter`가 + 동작하지 않습니다. ### 상속과 우선순위 @@ -194,17 +251,17 @@ DM Note는 Chromium 기반 WebView2에서 동작하므로 최신 CSS 기능을 ```css /* 우선순위: 1 (가장 낮음) */ -[data-state="active"] { +[data-state='active'] { --key-bg: red; } /* 우선순위: 2 */ -.blue[data-state="active"] { +.blue[data-state='active'] { --key-bg: blue; } /* 우선순위: 3 (가장 높음) */ -.blue.special[data-state="active"] { +.blue.special[data-state='active'] { --key-bg: purple; } ``` diff --git a/src/renderer/__tests__/customCssPriorityContract.test.ts b/src/renderer/__tests__/customCssPriorityContract.test.ts new file mode 100644 index 00000000..b0e82b2a --- /dev/null +++ b/src/renderer/__tests__/customCssPriorityContract.test.ts @@ -0,0 +1,142 @@ +import { readFileSync } from 'node:fs'; +import { resolve } from 'node:path'; +import { describe, expect, it } from 'vitest'; +import { computeKeyElementStyles } from '@hooks/overlay/useKeyElementStyles'; +import { getCounterTypographyStyle } from '@utils/core/counterStyles'; +import type { GradientSpec } from '@src/types/color'; + +const gradient: GradientSpec = { + angle: 135, + stops: [ + { color: '#ff0000', pos: 0 }, + { color: 'rgba(255,0,0,0)', pos: 1 }, + ], +}; + +describe('커스텀 CSS 우선순위 계약', () => { + it('일반 키 모드는 앱 외형을 inline 속성이 아닌 fallback 변수로만 제공한다', () => { + const { keyStyle, borderRingStyle, textStyle } = computeKeyElementStyles({ + active: false, + label: 'A', + position: { + dx: 0, + dy: 0, + width: 60, + height: 60, + backgroundGradient: gradient, + borderGradient: gradient, + borderWidth: 3, + borderRadius: 8, + fontSize: 18, + fontColor: '#abcdef', + useInlineStyles: false, + }, + }); + + expect(keyStyle.backgroundColor).toBeUndefined(); + expect(keyStyle.backgroundImage).toBeUndefined(); + expect(keyStyle.backgroundClip).toBeUndefined(); + expect(keyStyle.border).toBeUndefined(); + expect(keyStyle.borderRadius).toBeUndefined(); + expect(keyStyle.padding).toBeUndefined(); + expect(keyStyle.color).toBeUndefined(); + expect(keyStyle.fontSize).toBeUndefined(); + expect(keyStyle['--dmn-key-bg-image-default']).toContain('linear-gradient'); + expect(keyStyle['--dmn-key-border-default']).toBe('none'); + expect(keyStyle['--dmn-key-padding-default']).toBe('3px'); + expect(keyStyle['--dmn-key-radius-default']).toBe('8px'); + expect(keyStyle['--dmn-key-text-color-default']).toBe('#abcdef'); + expect(borderRingStyle?.background).toBeUndefined(); + expect(borderRingStyle?.['--dmn-border-gradient-image-default']).toContain( + 'linear-gradient', + ); + expect( + (keyStyle as Record)['--dmn-key-bg-image-default'], + ).toContain('linear-gradient'); + expect(textStyle.fontSize).toBe('inherit'); + expect(textStyle.fontWeight).toBe('inherit'); + }); + + it('인라인 우선 모드에서만 속성 패널 외형을 inline으로 승격한다', () => { + const { keyStyle, borderRingStyle } = computeKeyElementStyles({ + active: false, + label: 'A', + position: { + dx: 0, + dy: 0, + width: 60, + height: 60, + backgroundGradient: gradient, + borderGradient: gradient, + borderWidth: 3, + useInlineStyles: true, + }, + }); + + expect(keyStyle.backgroundImage).toContain('linear-gradient'); + expect(keyStyle.backgroundClip).toBe('padding-box'); + expect(keyStyle.border).toBe('none'); + expect(keyStyle.padding).toBe('3px'); + expect(borderRingStyle?.background).toContain('linear-gradient'); + }); + + it('카운터 타이포도 일반 모드에서는 fallback 변수만 사용한다', () => { + const fallback = getCounterTypographyStyle({ + fontSize: 22, + fontFamily: 'Example', + fontWeight: 700, + fontItalic: true, + fontUnderline: true, + }); + const inline = getCounterTypographyStyle({ + fontSize: 22, + fontWeight: 700, + useInlineStyles: true, + }); + + expect(fallback.fontSize).toBeUndefined(); + expect(fallback.fontWeight).toBeUndefined(); + expect(fallback['--dmn-counter-font-size-default']).toBe('22px'); + expect(fallback['--dmn-counter-font-weight-default']).toBe('700'); + expect(inline.fontSize).toBe('22px'); + expect(inline.fontWeight).toBe(700); + }); + + it('전역 앱 외형은 특이도 0 규칙이고 공개 변수가 그라데이션을 끈다', () => { + const css = readFileSync( + resolve(process.cwd(), 'src/renderer/styles/global.css'), + 'utf8', + ); + + expect(css).toMatch( + /:where\(\[data-key-element\]\)\s*\{[\s\S]*?background-image:\s*var\(\s*--key-bg-image,\s*var\(--key-bg,/, + ); + const rootRule = css.match( + /:where\(\[data-key-element\]\)\s*\{[\s\S]*?\n\}/, + )?.[0]; + expect(rootRule).toBeDefined(); + expect(rootRule).not.toMatch(/\n\s*background\s*:/); + expect(css).toMatch( + /:where\(\[data-key-element\] > \[data-gradient-border-ring\]\)\s*\{[\s\S]*?--key-border-image,[\s\S]*?var\(--key-border,/, + ); + expect(css).toMatch( + /:where\(\.counter\)\s*\{[\s\S]*?--counter-fill-image,[\s\S]*?--counter-color,/, + ); + expect(css).toMatch(/:where\(\[data-graph-element\]\)/); + expect(css).toMatch(/:where\(\[data-knob-element\]\)/); + }); + + it('ko/en 문서는 인라인 우선 모드를 직접 CSS 속성으로 덮어쓴다', () => { + for (const locale of ['en', 'ko']) { + const docs = readFileSync( + resolve( + process.cwd(), + `docs/content/${locale}/custom-css/variables/page.mdx`, + ), + 'utf8', + ); + expect(docs).not.toContain('--key-bg: #ff2b80 !important'); + expect(docs).toContain('background: #ff2b80 !important'); + } + }); +}); From 0e907deceea8ed5d8c9290e55100f48549b5cc5f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=97=B0=EC=9A=B0?= Date: Mon, 20 Jul 2026 00:56:12 +0900 Subject: [PATCH 2/6] =?UTF-8?q?feat:=20=EC=BB=A4=EC=8A=A4=ED=85=80=20?= =?UTF-8?q?=ED=83=AD=20=ED=8C=9D=EC=98=A4=EB=B2=84=EB=A5=BC=20=EC=B9=B4?= =?UTF-8?q?=EB=93=9C=C2=B7=EA=B2=80=EC=83=89=ED=98=95=EC=9C=BC=EB=A1=9C=20?= =?UTF-8?q?=EA=B0=9C=ED=8E=B8=ED=95=98=EA=B3=A0=20=EA=B2=80=EC=83=89=C2=B7?= =?UTF-8?q?=EC=B6=94=EA=B0=80=20=EC=BB=A8=ED=8A=B8=EB=A1=A4=20=EA=B3=B5?= =?UTF-8?q?=EC=9A=A9=ED=99=94?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/renderer/assets/svgs/plus.svg | 3 - src/renderer/assets/svgs/plus2.svg | 3 - .../main/Modal/FloatingPopup.test.tsx | 45 +++++ .../components/main/Modal/FloatingPopup.tsx | 12 +- .../components/main/Modal/ListPopup.tsx | 58 +++--- .../Modal/content/editors/TabNameModal.tsx | 2 +- .../content/pickers/CommonListPickerPage.tsx | 73 ++----- .../content/pickers/WebFontInputModal.tsx | 6 +- .../main/Modal/content/settings/TabList.tsx | 181 ++++++++++-------- src/renderer/components/main/Tool/TabTool.tsx | 3 +- .../components/main/common/AddIconButton.tsx | 38 ++++ .../components/main/common/SearchField.tsx | 48 +++++ src/renderer/locales/en.json | 5 +- src/renderer/locales/ko.json | 5 +- src/renderer/locales/ru.json | 5 +- src/renderer/locales/zh-Hant.json | 5 +- src/renderer/locales/zh-cn.json | 5 +- 17 files changed, 310 insertions(+), 187 deletions(-) delete mode 100644 src/renderer/assets/svgs/plus.svg delete mode 100644 src/renderer/assets/svgs/plus2.svg create mode 100644 src/renderer/components/main/common/AddIconButton.tsx create mode 100644 src/renderer/components/main/common/SearchField.tsx diff --git a/src/renderer/assets/svgs/plus.svg b/src/renderer/assets/svgs/plus.svg deleted file mode 100644 index a3487852..00000000 --- a/src/renderer/assets/svgs/plus.svg +++ /dev/null @@ -1,3 +0,0 @@ - - - diff --git a/src/renderer/assets/svgs/plus2.svg b/src/renderer/assets/svgs/plus2.svg deleted file mode 100644 index a099a860..00000000 --- a/src/renderer/assets/svgs/plus2.svg +++ /dev/null @@ -1,3 +0,0 @@ - - - diff --git a/src/renderer/components/main/Modal/FloatingPopup.test.tsx b/src/renderer/components/main/Modal/FloatingPopup.test.tsx index bec6fd41..3e97dabb 100644 --- a/src/renderer/components/main/Modal/FloatingPopup.test.tsx +++ b/src/renderer/components/main/Modal/FloatingPopup.test.tsx @@ -120,6 +120,51 @@ describe('FloatingPopup focus contract', () => { expect(document.activeElement).toBe(opener); }); + it('focuses the surface first when initialFocus is surface', async () => { + const opener = document.createElement('button'); + document.body.prepend(opener); + opener.focus(); + + const renderSurfacePopup = async (open: boolean) => { + await act(async () => { + root.render( + undefined} + > + {open ? : null} + , + ); + }); + }; + + await renderSurfacePopup(true); + const dialog = document.querySelector( + '[role="dialog"][aria-modal="false"]', + ); + // 입력 필드가 아니라 표면이 최초 포커스를 가짐 + expect(document.activeElement).toBe(dialog); + + // 첫 Tab은 첫 포커서블 자식으로 이동 + const forward = new KeyboardEvent('keydown', { + key: 'Tab', + bubbles: true, + cancelable: true, + }); + document.dispatchEvent(forward); + expect(forward.defaultPrevented).toBe(true); + expect(document.activeElement?.getAttribute('aria-label')).toBe('Search'); + + // 닫으면 opener로 복원 + await renderSurfacePopup(false); + expect(document.activeElement).toBe(opener); + }); + it('exposes an accessible dialog name', async () => { await renderPopup(true, ); diff --git a/src/renderer/components/main/Modal/FloatingPopup.tsx b/src/renderer/components/main/Modal/FloatingPopup.tsx index 9d01922c..b7c06b14 100644 --- a/src/renderer/components/main/Modal/FloatingPopup.tsx +++ b/src/renderer/components/main/Modal/FloatingPopup.tsx @@ -31,6 +31,8 @@ interface FloatingPopupBaseProps { animate?: boolean; onKeyDown?: React.KeyboardEventHandler; focusOriginRef?: React.MutableRefObject; + /** surface: 열릴 때 첫 포커서블 대신 팝업 표면에 포커스 (입력 필드 자동 포커스 방지) */ + initialFocus?: 'first' | 'surface'; } interface FloatingDialogPopupProps extends FloatingPopupBaseProps { @@ -71,6 +73,7 @@ interface FloatingPopupSurfaceProps { onMenuTab?: (event: KeyboardEvent) => void; onKeyDown?: React.KeyboardEventHandler; focusOriginRef?: React.MutableRefObject; + initialFocus?: 'first' | 'surface'; children?: React.ReactNode; } @@ -83,6 +86,7 @@ const FloatingPopupSurface = ({ onMenuTab, onKeyDown, focusOriginRef, + initialFocus = 'first', children, }: FloatingPopupSurfaceProps) => { const surfaceRef = useRef(null); @@ -107,13 +111,15 @@ const FloatingPopupSurface = ({ } if (surface.contains(document.activeElement)) return; const initialTarget = - role === 'menu' + initialFocus === 'surface' + ? surface + : role === 'menu' ? surface.querySelector( '[role^="menuitem"]:not(:disabled)', ) ?? surface : getFocusableElements(surface)[0] ?? surface; initialTarget.focus(); - }, [focusOriginRef, role]); + }, [focusOriginRef, initialFocus, role]); useLayoutEffect(() => { const prevFocused = prevFocusedRef.current; @@ -202,6 +208,7 @@ const FloatingPopup = ({ onKeyDown, onMenuTab, focusOriginRef, + initialFocus, }: FloatingPopupProps) => { const { x, y, refs, strategy, update } = useFloating({ placement: placement as Placement, @@ -474,6 +481,7 @@ const FloatingPopup = ({ onMenuTab={onMenuTab} onKeyDown={onKeyDown} focusOriginRef={focusOriginRef} + initialFocus={initialFocus} > {children} diff --git a/src/renderer/components/main/Modal/ListPopup.tsx b/src/renderer/components/main/Modal/ListPopup.tsx index 4194a57d..903ab30c 100644 --- a/src/renderer/components/main/Modal/ListPopup.tsx +++ b/src/renderer/components/main/Modal/ListPopup.tsx @@ -8,8 +8,6 @@ export type ListItem = { id: string; label: string; disabled?: boolean; - /** 구분선 항목 */ - type?: 'item' | 'separator'; /** 토글 항목의 체크 상태 */ checked?: boolean; /** 서브메뉴 항목 */ @@ -33,6 +31,24 @@ interface ListPopupProps { maxVisibleItems?: number; } +// 아이템 26 + 갭 4 리듬 공용 스크롤 계산 — 메인 메뉴·서브메뉴가 함께 사용 +const ITEM_HEIGHT = 26; +const ITEM_GAP = 4; +const SCROLL_EDGE_PADDING = 6; + +const getListScrollMetrics = ( + itemCount: number, + maxVisibleItems?: number, +): { needsScroll: boolean; maxHeight: number | undefined } => { + if (maxVisibleItems == null || itemCount <= maxVisibleItems) { + return { needsScroll: false, maxHeight: undefined }; + } + return { + needsScroll: true, + maxHeight: maxVisibleItems * (ITEM_HEIGHT + ITEM_GAP) + SCROLL_EDGE_PADDING, + }; +}; + const DOCUMENT_FOCUSABLE_SELECTOR = [ 'a[href]', 'button:not([disabled])', @@ -201,15 +217,10 @@ const SubMenu = ({ firstItem?.focus(); }, [focusFirst, pos]); - const itemHeight = 26; - const itemGap = 4; - const separatorCount = items.filter((i) => i.type === 'separator').length; - const normalItemCount = items.length - separatorCount; - const effectiveMax = maxVisibleItems ?? normalItemCount; - const needsScroll = normalItemCount > effectiveMax; - const maxHeight = needsScroll - ? effectiveMax * (itemHeight + itemGap) + separatorCount * (1 + itemGap) + 6 - : undefined; + const { needsScroll, maxHeight } = getListScrollMetrics( + items.length, + maxVisibleItems, + ); const hasCheckColumn = items.some((it) => typeof it.checked === 'boolean'); const { scrollContainerRef: subLenisRef } = useLenis({ @@ -345,15 +356,6 @@ const MenuItemRow = ({ }; }, []); - // 구분선 (부모 p-[4px] 패딩을 무시하고 전체 폭 사용) - if (item.type === 'separator') { - return ( -
-
-
- ); - } - const hasCheck = typeof item.checked === 'boolean'; const handleSelect = () => { @@ -513,18 +515,10 @@ const ListPopup = ({ 'z-40 bg-glass backdrop-glass-popup shadow-elevation-2 rounded-surface p-[4px] flex flex-col gap-[4px]'; const effectiveClassName = `${defaultClassName} ${className}`.trim(); - // 스크롤 필요 여부 계산 (아이템 26 + 갭 4 리듬) - const itemHeight = 26; - const itemGap = 4; - const separatorCount = items.filter((i) => i.type === 'separator').length; - const normalItemCount = items.length - separatorCount; - const needsScroll = - maxVisibleItems != null && normalItemCount > maxVisibleItems; - const maxHeight = needsScroll - ? maxVisibleItems * (itemHeight + itemGap) + - separatorCount * (1 + itemGap) + - 6 - : undefined; + const { needsScroll, maxHeight } = getListScrollMetrics( + items.length, + maxVisibleItems, + ); const hasCheckColumn = items.some((it) => typeof it.checked === 'boolean'); const siblingActiveRef = useRef<{ diff --git a/src/renderer/components/main/Modal/content/editors/TabNameModal.tsx b/src/renderer/components/main/Modal/content/editors/TabNameModal.tsx index 7ea5a5e6..6979092c 100644 --- a/src/renderer/components/main/Modal/content/editors/TabNameModal.tsx +++ b/src/renderer/components/main/Modal/content/editors/TabNameModal.tsx @@ -80,7 +80,7 @@ const TabNameModal = ({ className="w-full min-w-0 h-[30px] px-[12px] rounded-surface bg-inset text-fg text-body focus:shadow-focus-ring" placeholder={t('tabs.name.placeholder')} /> - {error &&
{error}
} + {error &&
{error}
}
{/* 검색 — 리스트와 인접한 프라이머리 컨트롤 */}
-
- - - - - onSearchQueryChange(event.target.value)} - onKeyDown={(event) => { - if (event.key !== 'Escape') return; - event.preventDefault(); - event.stopPropagation(); - onBack(); - }} - placeholder={searchPlaceholder} - aria-label={searchPlaceholder} - className="w-full h-[30px] pl-[30px] pr-[10px] bg-inset rounded-surface text-fg text-body placeholder-fg-faint focus:shadow-focus-ring outline-none transition-shadow duration-fast" - /> -
+ { + if (event.key !== 'Escape') return; + event.preventDefault(); + event.stopPropagation(); + onBack(); + }} + />
{/* 리스트 컨테이너 — 배경보다 한 단계 밝은 필 테이블. 빈 공간도 테이블의 빈 영역으로 읽힘 */}
@@ -151,7 +127,7 @@ export default function CommonListPickerPage({

) : null} {errorText ? ( -

+

{errorText}

) : null} @@ -172,23 +148,12 @@ export default function CommonListPickerPage({ />
) : null} - + label={addLabel} + className="ml-auto" + /> diff --git a/src/renderer/components/main/Modal/content/pickers/WebFontInputModal.tsx b/src/renderer/components/main/Modal/content/pickers/WebFontInputModal.tsx index 3ede14f3..9e13d1ee 100644 --- a/src/renderer/components/main/Modal/content/pickers/WebFontInputModal.tsx +++ b/src/renderer/components/main/Modal/content/pickers/WebFontInputModal.tsx @@ -432,11 +432,7 @@ const WebFontInputModal = ({ > {extractedFontFamily}

-