lightstair-css 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 박민권
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.ko.md ADDED
@@ -0,0 +1,122 @@
1
+ <p align="center">
2
+ <picture>
3
+ <source media="(prefers-color-scheme: dark)" srcset="./preview/icon-dark.svg">
4
+ <img src="./preview/icon-light.svg" alt="LightStair CSS logo" width="140">
5
+ </picture>
6
+ </p>
7
+
8
+ <h1 align="center">LightStair CSS</h1>
9
+
10
+ <p align="center">
11
+ <a href="README.ko.md">한국어</a> ·
12
+ <a href="README.md">English</a>
13
+ </p>
14
+
15
+ <p align="center">
16
+ <img src="https://img.shields.io/badge/License-MIT-yellow.svg" alt="License: MIT">
17
+ </p>
18
+
19
+ <p align="center">
20
+ OKLCH 색상 공간을 기반으로 글자·배경·테두리 색의 밝기 단계를 설정하여 CSS 파일을 생성하는 CLI 도구.
21
+ </p>
22
+
23
+ <p align="center">
24
+ <img src="./assets/demo.gif" alt="Demo" width="800">
25
+ </p>
26
+
27
+ ---
28
+
29
+ ## 이 도구가 필요한 이유
30
+
31
+ CSS에서 특정 색상의 밝기를 여러 단계로 선언해 두고 사용하고 싶을 때가 있습니다. 하지만 똑같은 밝기라도 글자·배경·테두리 색은 서로 다른 밝기 단계로 설정해야 합니다. 또한, 글자·배경·테두리 색의 조합도 맞춰봐야 하고 다크 모드까지 고려해야 합니다. 이 도구는 이러한 과정을 도와줍니다.
32
+
33
+ ## 기능 소개
34
+
35
+ - OKLCH 색상 공간 기반 설정
36
+ - OKLCH, RGB, HEX 색상 값으로 내보내기
37
+ - 다크 모드 지원
38
+ - 로컬 서버 미리보기 제공
39
+ - 글자색 × 배경색 × 테두리색 조합 기능
40
+ - 라이트/다크 테마 같이 보기
41
+
42
+ ## 설치
43
+
44
+ ```bash
45
+ npm install lightstair-css
46
+ ```
47
+
48
+ ## CLI
49
+
50
+ ### 도움말
51
+
52
+ ```bash
53
+ lightstair-css --help
54
+ ```
55
+
56
+ ### 설정 파일 생성
57
+
58
+ ```bash
59
+ lightstair-css init
60
+ ```
61
+
62
+ 실행 위치에 기본 [lightstair-css.yml](./templates/lightstair-css.yml) 설정 파일을 생성합니다. 설정 파일이 이미 존재하면 무시합니다.
63
+
64
+ ### CSS 파일 생성
65
+
66
+ ```bash
67
+ lightstair-css [options]
68
+ ```
69
+
70
+ 기본으로 실행 위치에 `lightstair-css.css` 파일을 생성합니다. 실행 위치에 설정 파일이 없는 경우 기본 설정 파일이 자동으로 생성됩니다.
71
+
72
+ 기본 포맷은 `OKLCH`이며 생성된 CSS 변수들의 색상 값은 동적으로 계산하는 코드로 생성됩니다. `--bake [format]` 옵션을 사용하면 계산된 색상 값으로 CSS를 생성할 수 있습니다.
73
+
74
+ ```css
75
+ /* 기본 코드 */
76
+ --tx-1: oklch(clamp(0, var(--tx-init-l) + var(--tx-l-gap) * 0, 1) var(--tx-base-c) var(--tx-base-h));
77
+
78
+ /* `--bake oklch` 옵션으로 계산된 코드 */
79
+ --tx-1: oklch(0.31 0.01 250);
80
+
81
+ /* `--bake rgb` 옵션으로 계산된 코드 */
82
+ --tx-1: rgb(44.585 48.788 53.291);
83
+
84
+ /* `--bake hex` 옵션으로 계산된 코드 */
85
+ --tx-1: #2d3135;
86
+ ```
87
+
88
+ ### 미리보기 서버 실행
89
+
90
+ ```bash
91
+ lightstair-css preview [options]
92
+ ```
93
+
94
+ 브라우저에서 `http://localhost:[port]`로 접속하면 미리보기 화면을 확인할 수 있습니다. 옵션으로 포트 번호를 명시하지 않으면 임의의 포트로 실행됩니다.
95
+
96
+ ### 명령어 예시
97
+
98
+ ```bash
99
+ # 도움말
100
+ lightstair-css --help
101
+
102
+ # 기본 설정 파일 생성
103
+ lightstair-css init
104
+
105
+ # 기본 설정으로 CSS 파일 생성
106
+ lightstair-css
107
+
108
+ # 설정 파일과 출력 파일 지정
109
+ lightstair-css -c my-config.yml -o dist/my-colors.css
110
+
111
+ # 계산된 RGB 포맷으로 CSS 파일 생성
112
+ lightstair-css --bake rgb
113
+
114
+ # 계산된 HEX 포맷으로 CSS 파일 생성 및 출력 파일 지정
115
+ lightstair-css --bake hex -o dist/my-colors.css
116
+
117
+ # 미리보기 서버 시작
118
+ lightstair-css preview
119
+
120
+ # 포트 및 설정 파일을 지정하여 미리보기 서버 시작
121
+ lightstair-css preview -p 3000 -c my-config.yml
122
+ ```
package/README.md ADDED
@@ -0,0 +1,122 @@
1
+ <p align="center">
2
+ <picture>
3
+ <source media="(prefers-color-scheme: dark)" srcset="./preview/icon-dark.svg">
4
+ <img src="./preview/icon-light.svg" alt="LightStair CSS logo" width="140">
5
+ </picture>
6
+ </p>
7
+
8
+ <h1 align="center">LightStair CSS</h1>
9
+
10
+ <p align="center">
11
+ <a href="README.ko.md">한국어</a> ·
12
+ <a href="README.md">English</a>
13
+ </p>
14
+
15
+ <p align="center">
16
+ <img src="https://img.shields.io/badge/License-MIT-yellow.svg" alt="License: MIT">
17
+ </p>
18
+
19
+ <p align="center">
20
+ A CLI tool that generates CSS files by setting brightness steps for text, background, and border colors based on the OKLCH color space.
21
+ </p>
22
+
23
+ <p align="center">
24
+ <img src="./assets/demo.gif" alt="Demo" width="800">
25
+ </p>
26
+
27
+ ---
28
+
29
+ ## Why This Tool
30
+
31
+ In CSS, you may want to declare and use multiple brightness steps for a given color. However, even for the same perceived brightness, text, background, and border colors require different brightness levels. You also need to match combinations of text, background, and border colors, and account for dark mode. This tool simplifies these processes.
32
+
33
+ ## Features
34
+
35
+ - Configuration based on the OKLCH color space
36
+ - Export in OKLCH, RGB, and HEX color formats
37
+ - Dark mode support
38
+ - Local server preview
39
+ - Text × Background × Border color combination preview
40
+ - Side-by-side light and dark theme views
41
+
42
+ ## Installation
43
+
44
+ ```bash
45
+ npm install lightstair-css
46
+ ```
47
+
48
+ ## CLI
49
+
50
+ ### Help
51
+
52
+ ```bash
53
+ lightstair-css --help
54
+ ```
55
+
56
+ ### Generate Configuration File
57
+
58
+ ```bash
59
+ lightstair-css init
60
+ ```
61
+
62
+ Creates a default [lightstair-css.yml](./templates/lightstair-css.yml) configuration file in the current directory. Skips if a configuration file already exists.
63
+
64
+ ### Generate CSS File
65
+
66
+ ```bash
67
+ lightstair-css [options]
68
+ ```
69
+
70
+ Generates a `lightstair-css.css` file in the current directory by default. If no configuration file exists, a default one is automatically created.
71
+
72
+ The default format is `OKLCH`, and the generated CSS variables use dynamically computed color values. Use the `--bake [format]` option to generate CSS with pre-computed color values.
73
+
74
+ ```css
75
+ /* Default code */
76
+ --tx-1: oklch(clamp(0, var(--tx-init-l) + var(--tx-l-gap) * 0, 1) var(--tx-base-c) var(--tx-base-h));
77
+
78
+ /* Pre-computed code with `--bake oklch` option */
79
+ --tx-1: oklch(0.31 0.01 250);
80
+
81
+ /* Pre-computed code with `--bake rgb` option */
82
+ --tx-1: rgb(44.585 48.788 53.291);
83
+
84
+ /* Pre-computed code with `--bake hex` option */
85
+ --tx-1: #2d3135;
86
+ ```
87
+
88
+ ### Run Preview Server
89
+
90
+ ```bash
91
+ lightstair-css preview [options]
92
+ ```
93
+
94
+ Open `http://localhost:[port]` in your browser to view the preview. If no port is specified, a random port is used.
95
+
96
+ ### Command Examples
97
+
98
+ ```bash
99
+ # Show help
100
+ lightstair-css --help
101
+
102
+ # Create default configuration file
103
+ lightstair-css init
104
+
105
+ # Generate CSS with default settings
106
+ lightstair-css
107
+
108
+ # Specify configuration and output files
109
+ lightstair-css -c my-config.yml -o dist/my-colors.css
110
+
111
+ # Generate CSS with pre-computed RGB format
112
+ lightstair-css --bake rgb
113
+
114
+ # Generate CSS with pre-computed HEX format and specify output file
115
+ lightstair-css --bake hex -o dist/my-colors.css
116
+
117
+ # Start preview server
118
+ lightstair-css preview
119
+
120
+ # Start preview server with port and configuration file
121
+ lightstair-css preview -p 3000 -c my-config.yml
122
+ ```
package/bin/cli.js ADDED
@@ -0,0 +1,3 @@
1
+ #!/usr/bin/env node
2
+
3
+ import '../src/cli.js';
package/package.json ADDED
@@ -0,0 +1,43 @@
1
+ {
2
+ "name": "lightstair-css",
3
+ "version": "0.2.0",
4
+ "description": "OKLCH 색상 공간을 기반으로 글자·배경·테두리 색의 밝기 단계를 설정하여 CSS 파일을 생성하는 CLI 도구.",
5
+ "keywords": [
6
+ "color",
7
+ "css",
8
+ "cli",
9
+ "theming",
10
+ "dark-mode",
11
+ "lightstair"
12
+ ],
13
+ "author": "박민권 <ani2life@gmail.com> (https://github.com/ani2life)",
14
+ "license": "MIT",
15
+ "homepage": "https://github.com/ani2life/lightstair-css",
16
+ "repository": {
17
+ "type": "git",
18
+ "url": "https://github.com/ani2life/lightstair-css"
19
+ },
20
+ "bugs": {
21
+ "url": "https://github.com/ani2life/lightstair-css/issues"
22
+ },
23
+ "type": "module",
24
+ "engines": {
25
+ "node": ">=18.0.0"
26
+ },
27
+ "bin": {
28
+ "lightstair-css": "./bin/cli.js"
29
+ },
30
+ "dependencies": {
31
+ "@toolsnap/css-minifier-tool": "^1.0.0",
32
+ "colorjs.io": "^0.6.1",
33
+ "commander": "^15.0.0",
34
+ "open": "^11.0.0",
35
+ "yaml": "^2.9.0"
36
+ },
37
+ "files": [
38
+ "bin/",
39
+ "src/",
40
+ "templates/",
41
+ "preview/"
42
+ ]
43
+ }
Binary file
@@ -0,0 +1 @@
1
+ <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512"><circle cx="256" cy="256" r="230" fill="#1e1e1e" stroke="#e0e0e0" stroke-width="24"/><rect width="288" height="48" x="112" y="350" fill="#4a4a4a" rx="16"/><rect width="240" height="48" x="160" y="278" fill="#787878" rx="16"/><rect width="192" height="48" x="208" y="206" fill="#b3b3b3" rx="16"/><rect width="144" height="48" x="256" y="134" fill="#fff" rx="16"/></svg>
@@ -0,0 +1 @@
1
+ <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512"><circle cx="256" cy="256" r="230" fill="#fff" stroke="#4a4a4a" stroke-width="24"/><rect width="288" height="48" x="112" y="350" fill="#212121" rx="16"/><rect width="240" height="48" x="160" y="278" fill="#424242" rx="16"/><rect width="192" height="48" x="208" y="206" fill="#757575" rx="16"/><rect width="144" height="48" x="256" y="134" fill="#bdbdbd" rx="16"/></svg>
@@ -0,0 +1,449 @@
1
+ <!DOCTYPE html>
2
+ <html lang="ko">
3
+ <head>
4
+
5
+ <meta charset="UTF-8">
6
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
7
+ <title>LightStair CSS</title>
8
+ <link href="/css" rel="stylesheet">
9
+ <style>
10
+ :root {
11
+ color-scheme: light dark;
12
+
13
+ --main-tx-color: light-dark(var(--preview-tx-color), var(--dark-preview-tx-color));
14
+ --main-bg-color: light-dark(var(--preview-bg-color), var(--dark-preview-bg-color));
15
+ }
16
+
17
+ html {
18
+ font-size: 16px;
19
+ }
20
+
21
+ body {
22
+ color: var(--main-tx-color);
23
+ background-color: var(--main-bg-color);
24
+ font-family: ui-monospace, SFMono-Regular, SF Mono, Menlo, Monaco, Consolas, "Liberation Mono", "Course New", monospace;
25
+ margin: 0;
26
+ padding: 2rem;
27
+ }
28
+
29
+ h1 {
30
+ display: flex;
31
+ align-items: center;
32
+ gap: 0.5em;
33
+ margin: 0 0 2rem 0;
34
+
35
+ img {
36
+ display: block;
37
+ width: 1.2em;
38
+ height: 1.2em;
39
+ }
40
+ }
41
+
42
+ main {
43
+ display: flex;
44
+ flex-wrap: wrap;
45
+ gap: 2rem;
46
+
47
+ .theme-column {
48
+ display: flex;
49
+ flex-direction: column;
50
+ gap: 1.5rem;
51
+ flex: 1;
52
+ min-width: 300px;
53
+ padding: 1.5rem;
54
+ border-radius: 8px;
55
+ color: var(--main-tx-color);
56
+ background-color: var(--main-bg-color);
57
+
58
+ box-shadow: 0 2px 12px rgba(0, 0, 0, 0.15);
59
+ @media (prefers-color-scheme: dark) {
60
+ box-shadow: 0 2px 12px rgba(255, 255, 255, 0.15);
61
+ }
62
+
63
+ h3 {
64
+ font-size: 1.5rem;
65
+ margin: 0;
66
+ }
67
+
68
+ section {
69
+ display: flex;
70
+ flex-wrap: wrap;
71
+ gap: 1rem;
72
+ }
73
+
74
+ hr {
75
+ border: none;
76
+ height: 1px;
77
+ margin: 0 -1.5em;
78
+ background-color: currentColor;
79
+ opacity: 0.2;
80
+ }
81
+ }
82
+ }
83
+
84
+ .overview-card {
85
+ position: relative;
86
+ display: flex;
87
+ flex-direction: column;
88
+ gap: 2rem;
89
+ width: 100%;
90
+ border-radius: 8px;
91
+
92
+ .overview-card__stairs {
93
+ position: relative;
94
+ display: flex;
95
+ flex-direction: column;
96
+ gap: 0.5rem;
97
+
98
+ /* horizontal dots */
99
+ &:not(:last-child)::after {
100
+ content: '';
101
+ position: absolute;
102
+ left: 50%;
103
+ top: 100%;
104
+ transform: translate(-50%, calc(1rem - 1.5px));
105
+ width: 3px;
106
+ height: 3px;
107
+ background: currentColor;
108
+ border-radius: 50%;
109
+ box-shadow:
110
+ -10px 0 0 0 currentColor,
111
+ 10px 0 0 0 currentColor;
112
+ }
113
+
114
+ .stair {
115
+ --checkbox-opacity: 0.3;
116
+
117
+ display: flex;
118
+ flex-wrap: wrap;
119
+ gap: 1em;
120
+ box-sizing: border-box;
121
+ font-size: 0.8rem;
122
+ padding: 0.5rem 1.5rem 0.5rem 2rem;
123
+ border-radius: 4px;
124
+ border: 1px solid transparent;
125
+ position: relative;
126
+ cursor: pointer;
127
+
128
+ /* checkbox background */
129
+ &::before {
130
+ opacity: var(--checkbox-opacity);
131
+
132
+ content: '';
133
+ position: absolute;
134
+ top: 50%;
135
+ left: 0.5rem;
136
+ transform: translateY(-50%);
137
+
138
+ width: 1rem;
139
+ height: 1rem;
140
+
141
+ background-color: var(--main-tx-color);
142
+ border-radius: 50%;
143
+ }
144
+ /* checkbox mark */
145
+ &::after {
146
+ opacity: var(--checkbox-opacity);
147
+
148
+ content: '';
149
+ position: absolute;
150
+ top: 50%;
151
+ left: 0.85rem;
152
+ transform: translateY(-60%) rotate(45deg);
153
+
154
+ width: 0.24rem;
155
+ height: 0.4rem;
156
+
157
+ border: solid var(--main-bg-color);
158
+ border-width: 0 0.1rem 0.1rem 0;
159
+ }
160
+
161
+ &:hover {
162
+ outline: 1px solid var(--main-tx-color);
163
+ }
164
+
165
+ &:has(> input[type="radio"]:checked) {
166
+ --checkbox-opacity: 1;
167
+ }
168
+
169
+ input[type="radio"] {
170
+ position: absolute;
171
+ width: 0;
172
+ height: 0;
173
+ opacity: 0;
174
+ pointer-events: none;
175
+ }
176
+
177
+ > span {
178
+ white-space: nowrap;
179
+
180
+ &:nth-of-type(2) {
181
+ width: 20ch;
182
+ }
183
+ &:nth-of-type(3) {
184
+ width: 25ch;
185
+ }
186
+ &:nth-of-type(4) {
187
+ width: 7ch;
188
+ }
189
+ }
190
+ }
191
+ }
192
+ }
193
+
194
+ .combo-card {
195
+ padding: 1rem;
196
+ border-radius: 8px;
197
+ font-size: 0.85rem;
198
+ border-width: 1px;
199
+ border-style: solid;
200
+ }
201
+ </style>
202
+ </head>
203
+ <body>
204
+
205
+ <header>
206
+ <h1>
207
+ <picture>
208
+ <source media="(prefers-color-scheme: dark)" srcset="/icon-dark.svg">
209
+ <img src="/icon-light.svg" alt="">
210
+ </picture>
211
+ LightStair CSS
212
+ </h1>
213
+ </header>
214
+
215
+ <main>
216
+ <div class="theme-column light-theme">
217
+ <h3>☀️ Light Theme</h3>
218
+ <section id="overview-light"></section>
219
+ <hr>
220
+ <section id="combo-light"></section>
221
+ </div>
222
+ <div class="theme-column dark-theme">
223
+ <h3>🌙 Dark Theme</h3>
224
+ <section id="overview-dark"></section>
225
+ <hr>
226
+ <section id="combo-dark"></section>
227
+ </div>
228
+ </main>
229
+
230
+ <template id="overview-card">
231
+ <div class="overview-card">
232
+ <div class="overview-card__stairs" data-color-type="tx"></div>
233
+ <div class="overview-card__stairs" data-color-type="bg"></div>
234
+ <div class="overview-card__stairs" data-color-type="bd"></div>
235
+ </div>
236
+ </template>
237
+
238
+ <template id="overview-card-stair">
239
+ <label class="stair">
240
+ <input type="radio">
241
+ <span>color-var</span>
242
+ <span>oklch(0 0 0)</span>
243
+ <span>rgb(0 0 0)</span>
244
+ <span>#000000</span>
245
+ </label>
246
+ </template>
247
+
248
+ <template id="combo-card">
249
+ <div class="combo-card"></div>
250
+ </template>
251
+
252
+ <script>
253
+ (async function () {
254
+ try {
255
+ const [configRes, colorVarsRes] = await Promise.all([
256
+ fetch('/config'),
257
+ fetch('/color-vars'),
258
+ ]);
259
+
260
+ if (!configRes.ok) {
261
+ throw new Error(`Config fetch failed: ${configRes.status}`);
262
+ }
263
+ if (!colorVarsRes.ok) {
264
+ throw new Error(`Color vars fetch failed: ${colorVarsRes.status}`);
265
+ }
266
+
267
+ const config = await configRes.json();
268
+ const colorVars = await colorVarsRes.json();
269
+
270
+ renderOverviewCards(config, colorVars, 'overview-light', false);
271
+ renderOverviewCards(config, colorVars, 'overview-dark', true);
272
+ renderComboCards(config, 'combo-light', false);
273
+ renderComboCards(config, 'combo-dark', true);
274
+ } catch (err) {
275
+ console.error('[Preview] Failed to load data:', err.message);
276
+ }
277
+
278
+ function colorVarName(prefix, num) {
279
+ return `--${prefix}-${num}`;
280
+ }
281
+
282
+ const lastCheckedRadio = new Map();
283
+
284
+ function createStairElement(colorType, theme, colorData, styleProp, colorVar) {
285
+ const radioName = `${colorType}-stair-${theme}`;
286
+
287
+ const template = document.getElementById('overview-card-stair');
288
+ const clone = document.importNode(template.content, true);
289
+ const label = clone.querySelector('.stair');
290
+ const radio = clone.querySelector('input[type="radio"]');
291
+
292
+ label.dataset.theme = theme;
293
+ label.dataset.colorType = colorType;
294
+
295
+ radio.name = radioName;
296
+ radio.dataset.theme = theme;
297
+ radio.dataset.colorType = colorType;
298
+ radio.dataset.colorVar = colorVar;
299
+
300
+ label.style[styleProp] = `var(${colorVar})`;
301
+ const spans = label.querySelectorAll(':scope > span');
302
+ spans[0].textContent = colorVar;
303
+ spans[1].textContent = colorData.oklch;
304
+ spans[2].textContent = colorData.rgb;
305
+ spans[3].textContent = colorData.hex;
306
+
307
+ radio.addEventListener('click', () => {
308
+ const groupName = radio.name;
309
+ const lastRadio = lastCheckedRadio.get(groupName);
310
+
311
+ if (lastRadio === radio) {
312
+ // 마지막 체크 요소가 동일하면 체크 해제
313
+ radio.checked = false;
314
+ lastCheckedRadio.delete(groupName);
315
+ } else {
316
+ lastCheckedRadio.set(groupName, radio);
317
+ }
318
+
319
+ document.querySelectorAll(`.stair[data-theme="${theme}"]:not([data-color-type="${colorType}"])`).forEach(stair => {
320
+ stair.style[styleProp] = radio.checked ? label.style[styleProp] : '';
321
+ });
322
+
323
+ filterComboCards();
324
+ });
325
+
326
+ return clone;
327
+ }
328
+
329
+ function filterComboCards() {
330
+ const filterData = {};
331
+
332
+ for (const [groupName, radio] of lastCheckedRadio) {
333
+ const theme = radio.dataset.theme;
334
+ const colorType = radio.dataset.colorType;
335
+ const colorVar = radio.dataset.colorVar;
336
+
337
+ if (!filterData[theme]) {
338
+ filterData[theme] = {};
339
+ }
340
+ filterData[theme][colorType] = colorVar;
341
+ }
342
+
343
+ document.querySelectorAll('.combo-card').forEach(card => {
344
+ let isVisible = true;
345
+ const cardTheme = card.dataset.theme;
346
+ const filter = filterData[cardTheme];
347
+
348
+ if (filter) {
349
+ if ('tx' in filter && card.dataset.txColorVar !== filter.tx) {
350
+ isVisible = false;
351
+ }
352
+ if ('bg' in filter && card.dataset.bgColorVar !== filter.bg) {
353
+ isVisible = false;
354
+ }
355
+ if ('bd' in filter && card.dataset.bdColorVar !== filter.bd) {
356
+ isVisible = false;
357
+ }
358
+ }
359
+
360
+ card.style.display = isVisible ? '' : 'none';
361
+ });
362
+ }
363
+
364
+ async function renderOverviewCards(config, colorVars, containerId, isDark = false) {
365
+ const {
366
+ tx_l_steps, bg_l_steps, bd_l_steps,
367
+ tx_prefix, bg_prefix, bd_prefix,
368
+ } = config;
369
+
370
+ const theme = isDark ? 'dark' : 'light';
371
+
372
+ const template = document.getElementById('overview-card');
373
+ const clone = document.importNode(template.content, true);
374
+ const card = clone.querySelector('.overview-card');
375
+
376
+ const configs = [
377
+ {
378
+ colorType: 'tx',
379
+ prefix: tx_prefix,
380
+ steps: tx_l_steps,
381
+ styleProp: 'color',
382
+ },
383
+ {
384
+ colorType: 'bg',
385
+ prefix: bg_prefix,
386
+ steps: bg_l_steps,
387
+ styleProp: 'backgroundColor',
388
+ },
389
+ {
390
+ colorType: 'bd',
391
+ prefix: bd_prefix,
392
+ steps: bd_l_steps,
393
+ styleProp: 'borderColor',
394
+ },
395
+ ];
396
+
397
+ for (const { colorType, prefix, steps, styleProp, container } of configs) {
398
+ for (let i = 1; i <= steps; i++) {
399
+ const colorVar = colorVarName(prefix, i);
400
+ const colorData = colorVars[colorVar][theme];
401
+ const container = clone.querySelector(`.overview-card__stairs[data-color-type="${colorType}"]`);
402
+ container.appendChild(
403
+ createStairElement(colorType, theme, colorData, styleProp, colorVar)
404
+ );
405
+ }
406
+ }
407
+
408
+ document.getElementById(containerId).appendChild(clone);
409
+ }
410
+
411
+ function renderComboCards(config, containerId, isDark = false) {
412
+ const {
413
+ tx_l_steps, bg_l_steps, bd_l_steps,
414
+ tx_prefix, bg_prefix, bd_prefix,
415
+ } = config;
416
+
417
+ const theme = isDark ? 'dark' : 'light';
418
+
419
+ const template = document.getElementById('combo-card');
420
+
421
+ for (let bg = 1; bg <= bg_l_steps; bg++) {
422
+ for (let tx = 1; tx <= tx_l_steps; tx++) {
423
+ for (let bd = 1; bd <= bd_l_steps; bd++) {
424
+ const clone = document.importNode(template.content, true);
425
+ const card = clone.querySelector('.combo-card');
426
+
427
+ const txVar = colorVarName(tx_prefix, tx);
428
+ const bgVar = colorVarName(bg_prefix, bg);
429
+ const bdVar = colorVarName(bd_prefix, bd);
430
+ card.style.color = `var(${txVar})`;
431
+ card.style.backgroundColor = `var(${bgVar})`;
432
+ card.style.borderColor = `var(${bdVar})`;
433
+ card.innerHTML = `${txVar}<br>${bgVar}<br>${bdVar}<br>`;
434
+
435
+ card.dataset.theme = theme;
436
+ card.dataset.txColorVar = txVar;
437
+ card.dataset.bgColorVar = bgVar;
438
+ card.dataset.bdColorVar = bdVar;
439
+
440
+ document.getElementById(containerId).appendChild(clone);
441
+ }
442
+ }
443
+ }
444
+ }
445
+ })();
446
+ </script>
447
+
448
+ </body>
449
+ </html>
package/src/cli.js ADDED
@@ -0,0 +1,203 @@
1
+ import { readFileSync, writeFileSync, mkdirSync, existsSync, copyFileSync } from 'node:fs';
2
+ import { resolve, dirname, join } from 'node:path';
3
+ import { fileURLToPath } from 'node:url';
4
+ import http from 'node:http';
5
+ import { Command, Option } from 'commander';
6
+ import { parse } from 'yaml';
7
+ import { beautify } from '@toolsnap/css-minifier-tool';
8
+ import { generateCSS, generateBakedCSS, generateColorVars } from './generator.js';
9
+
10
+ const SRC_DIR = dirname(fileURLToPath(import.meta.url));
11
+ const DEFAULT_CONFIG_FILE = 'lightstair-css.yml';
12
+ const DEFAULT_CONFIG_PATH = resolve(SRC_DIR, '..', 'templates', DEFAULT_CONFIG_FILE);
13
+
14
+ const program = new Command();
15
+ const configOption = new Option('-c, --config <path>', 'path to configuration file');
16
+
17
+ function loadConfig(configPath) {
18
+ const raw = readFileSync(configPath, 'utf-8');
19
+ const userConfig = parse(raw);
20
+
21
+ const defaultRaw = readFileSync(DEFAULT_CONFIG_PATH, 'utf-8');
22
+ const defaultConfig = parse(defaultRaw);
23
+
24
+ const config = Object.assign({}, defaultConfig, userConfig);
25
+ return config;
26
+ }
27
+
28
+ function ensureConfigFile(configPath, { suppressIfExists = false } = {}) {
29
+ const targetPath = resolve(configPath);
30
+ if (existsSync(targetPath)) {
31
+ if (!suppressIfExists) console.log(`[OK] Config already exists: ${targetPath}`);
32
+ return;
33
+ }
34
+ copyFileSync(DEFAULT_CONFIG_PATH, targetPath);
35
+ console.log(`[OK] Created: ${targetPath}`);
36
+ }
37
+
38
+ function resolveAndEnsureConfigFile(options, { suppressIfExists = true } = {}) {
39
+ const configPath = options.config === undefined
40
+ ? resolve(process.cwd(), DEFAULT_CONFIG_FILE)
41
+ : resolve(options.config);
42
+
43
+ if (options.config === undefined) {
44
+ ensureConfigFile(configPath, { suppressIfExists });
45
+ }
46
+
47
+ return configPath;
48
+ }
49
+
50
+ function beautifyCss(css) {
51
+ return beautify(css)
52
+ // 시작 공백을 제외한 중간 공백을 1개로 치환.
53
+ .replace(/(?<=\S)[ ]{2,}/g, ' ');
54
+ }
55
+
56
+ program
57
+ .name('lightstair-css')
58
+ .description('LightStair CSS CLI tool for generating color values')
59
+ .version('0.1.0')
60
+ .addOption(configOption)
61
+ .option('-o, --output <path>', 'output CSS file path', 'lightstair-css.css')
62
+ .option('--bake [format]', 'bake CSS variables into computed values (oklch, rgb, hex)')
63
+ .action(() => {
64
+ const options = program.opts();
65
+ const configPath = resolveAndEnsureConfigFile(options, { suppressIfExists: true });
66
+
67
+ const outputPath = resolve(options.output);
68
+ const bakeFormat = (() => {
69
+ if (options.bake === undefined) return null;
70
+ if (options.bake === true) return 'oklch';
71
+ return options.bake;
72
+ })();
73
+
74
+ try {
75
+ const config = loadConfig(configPath);
76
+
77
+ const rawCss = bakeFormat
78
+ ? generateBakedCSS(config, bakeFormat)
79
+ : generateCSS(config);
80
+
81
+ const css = beautifyCss(rawCss);
82
+
83
+ const outputDir = dirname(outputPath);
84
+ mkdirSync(outputDir, { recursive: true });
85
+ writeFileSync(outputPath, css, 'utf-8');
86
+ console.log(`[OK] Output written to: ${outputPath}`);
87
+ } catch (err) {
88
+ console.error(`[Error] Failed to process config: ${configPath}`);
89
+ console.error(err.message);
90
+ process.exit(1);
91
+ }
92
+ });
93
+
94
+ program.command('init').description('create a default lightstair-css.yml config file').action(() => {
95
+ const targetPath = resolve(process.cwd(), DEFAULT_CONFIG_FILE);
96
+ ensureConfigFile(targetPath, { suppressIfExists: false });
97
+ process.exit(0);
98
+ });
99
+
100
+ program
101
+ .command('preview')
102
+ .description('run the preview server')
103
+ .addOption(configOption)
104
+ .option('-p, --port <number>', 'port number to use (default: random)')
105
+ .action((options) => {
106
+ const configPath = resolveAndEnsureConfigFile(options, { suppressIfExists: true });
107
+
108
+ const port = options.port !== undefined
109
+ ? parseInt(options.port, 10)
110
+ : 0;
111
+
112
+ if (port !== 0 && (isNaN(port) || port < 1 || port > 65535)) {
113
+ console.error('[Error] Invalid port number. Must be 1-65535.');
114
+ process.exit(1);
115
+ }
116
+
117
+ const server = http.createServer((req, res) => {
118
+ if (req.url === '/') {
119
+ const htmlPath = resolve(SRC_DIR, '..', 'preview', 'index.html');
120
+ const htmlContent = readFileSync(htmlPath, 'utf-8');
121
+ res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' });
122
+ res.end(htmlContent);
123
+ return;
124
+ }
125
+
126
+ if (req.url === '/css') {
127
+ try {
128
+ const config = loadConfig(configPath);
129
+ const css = generateCSS(config, { isPreview: true });
130
+ res.writeHead(200, { 'Content-Type': 'text/css; charset=utf-8' });
131
+ res.end(css, 'utf-8');
132
+ } catch (err) {
133
+ console.error(`[Error] CSS generation failed: ${configPath}`);
134
+ console.error(err.message);
135
+ res.writeHead(500);
136
+ res.end(`Error: ${err.message}`, 'utf-8');
137
+ }
138
+ return;
139
+ }
140
+
141
+ if (req.url === '/config') {
142
+ try {
143
+ const config = loadConfig(configPath);
144
+ res.writeHead(200, { 'Content-Type': 'application/json; charset=utf-8' });
145
+ res.end(JSON.stringify(config), 'utf-8');
146
+ } catch (err) {
147
+ console.error(`[Error] Config load failed: ${configPath}`);
148
+ console.error(err.message);
149
+ res.writeHead(500);
150
+ res.end(`Error: ${err.message}`, 'utf-8');
151
+ }
152
+ return;
153
+ }
154
+
155
+ if (req.url === '/color-vars') {
156
+ try {
157
+ const config = loadConfig(configPath);
158
+ const colorVars = generateColorVars(config);
159
+ res.writeHead(200, { 'Content-Type': 'application/json; charset=utf-8' });
160
+ res.end(JSON.stringify(colorVars), 'utf-8');
161
+ } catch (err) {
162
+ console.error(`[Error] Variables generation failed: ${configPath}`);
163
+ console.error(err.message);
164
+ res.writeHead(500);
165
+ res.end(`Error: ${err.message}`, 'utf-8');
166
+ }
167
+ return;
168
+ }
169
+
170
+ if (req.url.endsWith('.svg') || req.url.endsWith('.ico')) {
171
+ const filePath = join(SRC_DIR, '..', 'preview', req.url.replace(/^\//, ''));
172
+ const contentType = req.url.endsWith('.svg')
173
+ ? 'image/svg+xml'
174
+ : 'image/x-icon';
175
+ try {
176
+ const content = readFileSync(filePath);
177
+ res.writeHead(200, { 'Content-Type': contentType });
178
+ res.end(content);
179
+ } catch {
180
+ res.writeHead(404);
181
+ res.end('Not Found');
182
+ }
183
+ return;
184
+ }
185
+
186
+ res.writeHead(404);
187
+ res.end('Not Found');
188
+ });
189
+
190
+ server.listen(port, () => {
191
+ const actualPort = server.address().port;
192
+ console.log(`[OK] Preview server running at http://localhost:${actualPort}`);
193
+ console.log('Press Ctrl+C to stop the server.');
194
+ });
195
+
196
+ process.on('SIGINT', () => {
197
+ console.log('\nShutting down preview server...');
198
+ server.close();
199
+ process.exit(0);
200
+ });
201
+ });
202
+
203
+ program.parse(process.argv);
@@ -0,0 +1,214 @@
1
+ import Color from 'colorjs.io';
2
+
3
+ export function generateCSS(config, { isPreview = false } = {}) {
4
+ const {
5
+ tx_base_c, tx_base_h,
6
+ bg_base_c, bg_base_h,
7
+ bd_base_c, bd_base_h,
8
+ tx_init_l, bg_init_l, bd_init_l,
9
+ dark_tx_init_l, dark_bg_init_l, dark_bd_init_l,
10
+ tx_l_gap, bg_l_gap, bd_l_gap,
11
+ dark_tx_l_gap, dark_bg_l_gap, dark_bd_l_gap,
12
+ tx_l_steps, bg_l_steps, bd_l_steps,
13
+ tx_prefix, bg_prefix, bd_prefix,
14
+ } = config;
15
+
16
+ function generateSteps(prefix, steps) {
17
+ const initL = `var(--${prefix}-init-l)`;
18
+ const lGap = `var(--${prefix}-l-gap)`;
19
+ const baseC = `var(--${prefix}-base-c)`;
20
+ const baseH = `var(--${prefix}-base-h)`;
21
+
22
+ return Array.from({ length: steps }, (_, i) => {
23
+ const calcL = `clamp(0, ${initL} + ${lGap} * ${i}, 1)`;
24
+ return `--${prefix}-${i + 1}: oklch(${calcL} ${baseC} ${baseH});`
25
+ }).join('\n');
26
+ }
27
+
28
+ const txStepsCss = generateSteps(tx_prefix, tx_l_steps);
29
+ const bgStepsCss = generateSteps(bg_prefix, bg_l_steps);
30
+ const bdStepsCss = generateSteps(bd_prefix, bd_l_steps);
31
+
32
+ const baseCss = `
33
+ --${tx_prefix}-base-c: ${tx_base_c};
34
+ --${tx_prefix}-base-h: ${tx_base_h};
35
+ --${bg_prefix}-base-c: ${bg_base_c};
36
+ --${bg_prefix}-base-h: ${bg_base_h};
37
+ --${bd_prefix}-base-c: ${bd_base_c};
38
+ --${bd_prefix}-base-h: ${bd_base_h};
39
+ `;
40
+
41
+ const lightThemeCss = `
42
+ --${tx_prefix}-init-l: ${tx_init_l};
43
+ --${bg_prefix}-init-l: ${bg_init_l};
44
+ --${bd_prefix}-init-l: ${bd_init_l};
45
+
46
+ --${tx_prefix}-l-gap: ${tx_l_gap};
47
+ --${bg_prefix}-l-gap: ${bg_l_gap};
48
+ --${bd_prefix}-l-gap: ${bd_l_gap};
49
+ `;
50
+
51
+ const darkThemeCss = `
52
+ --${tx_prefix}-init-l: ${dark_tx_init_l};
53
+ --${bg_prefix}-init-l: ${dark_bg_init_l};
54
+ --${bd_prefix}-init-l: ${dark_bd_init_l};
55
+
56
+ --${tx_prefix}-l-gap: ${dark_tx_l_gap};
57
+ --${bg_prefix}-l-gap: ${dark_bg_l_gap};
58
+ --${bd_prefix}-l-gap: ${dark_bd_l_gap};
59
+ `;
60
+
61
+ if (isPreview) {
62
+ const {
63
+ preview_tx_l, preview_tx_c, preview_tx_h,
64
+ preview_bg_l, preview_bg_c, preview_bg_h,
65
+ dark_preview_tx_l, dark_preview_tx_c, dark_preview_tx_h,
66
+ dark_preview_bg_l, dark_preview_bg_c, dark_preview_bg_h,
67
+ } = config;
68
+
69
+ const previewColorVars = `
70
+ --preview-tx-color: oklch(${preview_tx_l} ${preview_tx_c} ${preview_tx_h});
71
+ --preview-bg-color: oklch(${preview_bg_l} ${preview_bg_c} ${preview_bg_h});
72
+ --dark-preview-tx-color: oklch(${dark_preview_tx_l} ${dark_preview_tx_c} ${dark_preview_tx_h});
73
+ --dark-preview-bg-color: oklch(${dark_preview_bg_l} ${dark_preview_bg_c} ${dark_preview_bg_h});
74
+ `;
75
+
76
+ return /* css */`
77
+ :root {
78
+ ${previewColorVars}
79
+
80
+ ${baseCss}
81
+ }
82
+
83
+ .light-theme,
84
+ .dark-theme {
85
+ ${txStepsCss}
86
+ ${bgStepsCss}
87
+ ${bdStepsCss}
88
+ }
89
+
90
+ .light-theme {
91
+ color-scheme: light;
92
+ ${lightThemeCss}
93
+ }
94
+
95
+ .dark-theme {
96
+ color-scheme: dark;
97
+ ${darkThemeCss}
98
+ }
99
+ `;
100
+ } else {
101
+ return /* css */`
102
+ :root {
103
+ ${baseCss}
104
+
105
+ ${lightThemeCss}
106
+
107
+ @media (prefers-color-scheme: dark) {
108
+ ${darkThemeCss}
109
+ }
110
+
111
+ ${txStepsCss}
112
+ ${bgStepsCss}
113
+ ${bdStepsCss}
114
+ }
115
+ `;
116
+ }
117
+ }
118
+
119
+ function clamp(min, val, max) {
120
+ return Math.max(min, Math.min(val, max));
121
+ }
122
+
123
+ function formatColor(l, c, h) {
124
+ const color = new Color({ spaceId: 'oklch', coords: [l, c, h] });
125
+ return {
126
+ oklch: color.toString({
127
+ format: 'oklch',
128
+ coords: ['<number>', '<number>', '<number>']
129
+ }),
130
+ rgb: color.toString({
131
+ format: 'rgb',
132
+ coords: ['<number>[0, 255]', '<number>[0, 255]', '<number>[0, 255]']
133
+ }),
134
+ hex: color.toString({ format: 'hex' }),
135
+ };
136
+ }
137
+
138
+ export function generateBakedCSS(config, format) {
139
+ const {
140
+ tx_base_c, tx_base_h,
141
+ bg_base_c, bg_base_h,
142
+ bd_base_c, bd_base_h,
143
+ tx_init_l, bg_init_l, bd_init_l,
144
+ dark_tx_init_l, dark_bg_init_l, dark_bd_init_l,
145
+ tx_l_gap, bg_l_gap, bd_l_gap,
146
+ dark_tx_l_gap, dark_bg_l_gap, dark_bd_l_gap,
147
+ tx_l_steps, bg_l_steps, bd_l_steps,
148
+ tx_prefix, bg_prefix, bd_prefix,
149
+ } = config;
150
+
151
+ function generateSteps(prefix, initL, gap, steps, c, h, format) {
152
+ return Array.from({ length: steps }, (_, i) => {
153
+ const l = clamp(0, initL + gap * i, 1);
154
+ return `--${prefix}-${i + 1}: ${formatColor(l, c, h)[format]};`;
155
+ }).join('\n');
156
+ }
157
+
158
+ const txSteps = generateSteps(tx_prefix, tx_init_l, tx_l_gap, tx_l_steps, tx_base_c, tx_base_h, format);
159
+ const bgSteps = generateSteps(bg_prefix, bg_init_l, bg_l_gap, bg_l_steps, bg_base_c, bg_base_h, format);
160
+ const bdSteps = generateSteps(bd_prefix, bd_init_l, bd_l_gap, bd_l_steps, bd_base_c, bd_base_h, format);
161
+
162
+ const txStepsDark = generateSteps(tx_prefix, dark_tx_init_l, dark_tx_l_gap, tx_l_steps, tx_base_c, tx_base_h, format);
163
+ const bgStepsDark = generateSteps(bg_prefix, dark_bg_init_l, dark_bg_l_gap, bg_l_steps, bg_base_c, bg_base_h, format);
164
+ const bdStepsDark = generateSteps(bd_prefix, dark_bd_init_l, dark_bd_l_gap, bd_l_steps, bd_base_c, bd_base_h, format);
165
+
166
+ return /* css */`
167
+ :root {
168
+ ${txSteps}
169
+ ${bgSteps}
170
+ ${bdSteps}
171
+
172
+ @media (prefers-color-scheme: dark) {
173
+ ${txStepsDark}
174
+ ${bgStepsDark}
175
+ ${bdStepsDark}
176
+ }
177
+ }
178
+ `;
179
+ }
180
+
181
+ export function generateColorVars(config) {
182
+ const {
183
+ tx_base_c, tx_base_h,
184
+ bg_base_c, bg_base_h,
185
+ bd_base_c, bd_base_h,
186
+ tx_init_l, bg_init_l, bd_init_l,
187
+ dark_tx_init_l, dark_bg_init_l, dark_bd_init_l,
188
+ tx_l_gap, bg_l_gap, bd_l_gap,
189
+ dark_tx_l_gap, dark_bg_l_gap, dark_bd_l_gap,
190
+ tx_l_steps, bg_l_steps, bd_l_steps,
191
+ tx_prefix, bg_prefix, bd_prefix,
192
+ } = config;
193
+
194
+ const result = {};
195
+
196
+ function addSteps(prefix, lightInitL, lightGap, darkInitL, darkGap, steps, c, h) {
197
+ for (let i = 0; i < steps; i++) {
198
+ const key = `--${prefix}-${i + 1}`;
199
+ const lightL = clamp(0, lightInitL + lightGap * i, 1);
200
+ const darkL = clamp(0, darkInitL + darkGap * i, 1);
201
+
202
+ result[key] = {
203
+ light: formatColor(lightL, c, h),
204
+ dark: formatColor(darkL, c, h),
205
+ };
206
+ }
207
+ }
208
+
209
+ addSteps(tx_prefix, tx_init_l, tx_l_gap, dark_tx_init_l, dark_tx_l_gap, tx_l_steps, tx_base_c, tx_base_h);
210
+ addSteps(bg_prefix, bg_init_l, bg_l_gap, dark_bg_init_l, dark_bg_l_gap, bg_l_steps, bg_base_c, bg_base_h);
211
+ addSteps(bd_prefix, bd_init_l, bd_l_gap, dark_bd_init_l, dark_bd_l_gap, bd_l_steps, bd_base_c, bd_base_h);
212
+
213
+ return result;
214
+ }
@@ -0,0 +1,57 @@
1
+ # ================================================
2
+ # L, C, H 값은 OKLCH 색상 공간을 기준으로 합니다.
3
+ # - L (Lightness, 명도): 0 ~ 1
4
+ # - C (Chroma, 채도): 0 ~ 약 0.4
5
+ # - H (Hue, 색상): 0 ~ 360 (각도, deg 단위)
6
+ # ================================================
7
+
8
+ tx_base_c: 0.01 # 기본 글자 채도
9
+ tx_base_h: 250 # 기본 글자 색상
10
+
11
+ bg_base_c: 0.01 # 기본 배경 채도
12
+ bg_base_h: 250 # 기본 배경 색상
13
+
14
+ bd_base_c: 0.01 # 기본 테두리 채도
15
+ bd_base_h: 250 # 기본 테두리 색상
16
+
17
+ tx_init_l: 0.31 # 초기 글자 밝기
18
+ bg_init_l: 0.96 # 초기 배경 밝기
19
+ bd_init_l: 0.88 # 초기 테두리 밝기
20
+
21
+ dark_tx_init_l: 0.85 # 다크모드 초기 글자 밝기
22
+ dark_bg_init_l: 0.22 # 다크모드 초기 배경 밝기
23
+ dark_bd_init_l: 0.38 # 다크모드 초기 테두리 밝기
24
+
25
+ tx_l_gap: 0.09 # 글자 밝기 간격
26
+ bg_l_gap: -0.04 # 배경 밝기 간격
27
+ bd_l_gap: -0.08 # 테두리 밝기 간격
28
+
29
+ dark_tx_l_gap: -0.09 # 다크모드 글자 밝기 간격
30
+ dark_bg_l_gap: 0.04 # 다크모드 배경 밝기 간격
31
+ dark_bd_l_gap: 0.10 # 다크모드 테두리 밝기 간격
32
+
33
+ tx_l_steps: 4 # 글자 밝기 단계
34
+ bg_l_steps: 4 # 배경 밝기 단계
35
+ bd_l_steps: 4 # 테두리 밝기 단계
36
+
37
+ tx_prefix: tx # 글자색 CSS 변수 접두어
38
+ bg_prefix: bg # 배경색 CSS 변수 접두어
39
+ bd_prefix: bd # 테두리색 CSS 변수 접두어
40
+
41
+ # ================================================
42
+ # 미리보기 페이지의 글자색과 배경색
43
+ # ================================================
44
+
45
+ preview_tx_l: 0.22 # 미리보기 글자 밝기
46
+ preview_tx_c: 0 # 미리보기 글자 채도
47
+ preview_tx_h: 0 # 미리보기 글자 색상
48
+ preview_bg_l: 1 # 미리보기 배경 밝기
49
+ preview_bg_c: 0 # 미리보기 배경 채도
50
+ preview_bg_h: 0 # 미리보기 배경 색상
51
+
52
+ dark_preview_tx_l: 0.94 # 다크모드 미리보기 글자 밝기
53
+ dark_preview_tx_c: 0 # 다크모드 미리보기 글자 채도
54
+ dark_preview_tx_h: 0 # 다크모드 미리보기 글자 색상
55
+ dark_preview_bg_l: 0.18 # 다크모드 미리보기 배경 밝기
56
+ dark_preview_bg_c: 0 # 다크모드 미리보기 배경 채도
57
+ dark_preview_bg_h: 0 # 다크모드 미리보기 배경 색상