bluedither 1.0.0 → 1.0.2

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.
@@ -0,0 +1,283 @@
1
+ /**
2
+ * BlueDither Fine-Tuner Dev Server
3
+ *
4
+ * Serves the rendered theme with tuner overlay.
5
+ * Computes clamp() values from reference px values at render time.
6
+ * Handles "commit" to write tokens back to disk.
7
+ *
8
+ * Usage: node fine-tuner/server.js [--port 3333]
9
+ */
10
+
11
+ import { readFileSync, writeFileSync } from 'fs';
12
+ import { createServer } from 'http';
13
+ import { resolve, extname, dirname } from 'path';
14
+ import { fileURLToPath } from 'url';
15
+
16
+ const __dirname = dirname(fileURLToPath(import.meta.url));
17
+ // BD_THEME_DIR env var is set by `bluedither tune` for installed themes
18
+ const THEME_DIR = process.env.BD_THEME_DIR || resolve(__dirname, '..', 'theme');
19
+ const TOKENS_PATH = resolve(THEME_DIR, 'tokens.json');
20
+ const PORT = parseInt(process.argv.find((_, i, a) => a[i - 1] === '--port') || '3333', 10);
21
+
22
+ function loadTokens() {
23
+ return JSON.parse(readFileSync(TOKENS_PATH, 'utf-8'));
24
+ }
25
+
26
+ function resolveTokenPath(obj, path) {
27
+ return path.split('.').reduce((o, k) => (o && o[k] !== undefined ? o[k] : ''), obj);
28
+ }
29
+
30
+ /**
31
+ * Compute a clamp() value from a reference px size.
32
+ * Formula: clamp(minRem, vwValue, maxRem)
33
+ * - max = referencePx / 16 (rem at design width)
34
+ * - vw = referencePx / designWidth * 100
35
+ * - min = max * 0.55 (scales down to ~55% on small screens)
36
+ */
37
+ function pxToClamp(px, designWidth) {
38
+ const maxRem = px / 16;
39
+ const vw = (px / designWidth) * 100;
40
+ const minRem = maxRem * 0.55;
41
+ return `clamp(${minRem.toFixed(4)}rem, ${vw.toFixed(4)}vw, ${maxRem.toFixed(4)}rem)`;
42
+ }
43
+
44
+ /**
45
+ * Build computed clamp values from tokens.
46
+ */
47
+ function buildClampValues(tokens) {
48
+ const dw = tokens.layout.designWidth;
49
+ const t = tokens.typography;
50
+ const s = tokens.spacing;
51
+
52
+ return {
53
+ headline: {
54
+ fontSize: pxToClamp(t.headline.referencePx, dw),
55
+ lineHeight: pxToClamp(t.headline.lineHeightPx, dw),
56
+ },
57
+ subHeadline: {
58
+ fontSize: pxToClamp(t.subHeadline.referencePx, dw),
59
+ lineHeight: pxToClamp(t.subHeadline.lineHeightPx, dw),
60
+ },
61
+ logo: {
62
+ fontSize: pxToClamp(t.logo.referencePx, dw),
63
+ lineHeight: pxToClamp(t.logo.lineHeightPx, dw),
64
+ },
65
+ navItem: {
66
+ fontSize: pxToClamp(t.navItem.referencePx, dw),
67
+ lineHeight: pxToClamp(t.navItem.lineHeightPx, dw),
68
+ },
69
+ ctaButton: {
70
+ fontSize: pxToClamp(t.ctaButton.referencePx, dw),
71
+ lineHeight: pxToClamp(t.ctaButton.lineHeightPx, dw),
72
+ },
73
+ spacing: {
74
+ headerPaddingX: pxToClamp(s.headerPaddingX, dw),
75
+ headerPaddingY: pxToClamp(s.headerPaddingY, dw),
76
+ heroPaddingTop: pxToClamp(s.heroPaddingTop, dw),
77
+ heroPaddingBottom: pxToClamp(s.heroPaddingBottom, dw),
78
+ heroPaddingX: pxToClamp(s.heroPaddingX, dw),
79
+ navGap: pxToClamp(s.navGap, dw),
80
+ ctaPaddingX: pxToClamp(s.ctaPaddingX, dw),
81
+ ctaPaddingY: pxToClamp(s.ctaPaddingY, dw),
82
+ ctaBorderRadius: `${(s.ctaBorderRadius / 16).toFixed(4)}rem`,
83
+ },
84
+ };
85
+ }
86
+
87
+ function renderFullPage(tokens) {
88
+ let template = readFileSync(resolve(THEME_DIR, 'template', 'index.html'), 'utf-8');
89
+
90
+ // Build clamp values
91
+ const clamp = buildClampValues(tokens);
92
+ const renderData = { ...tokens, clamp };
93
+
94
+ // Handle font URL encoding for Google Fonts links
95
+ template = template.replace(/\{\{typography\.primaryFont\.urlEncoded\}\}/g,
96
+ encodeURIComponent(tokens.typography.primaryFont));
97
+ template = template.replace(/\{\{typography\.secondaryFont\.urlEncoded\}\}/g,
98
+ encodeURIComponent(tokens.typography.secondaryFont));
99
+
100
+ // Handle {{#each}} blocks
101
+ template = template.replace(
102
+ /\{\{#each\s+([^}]+)\}\}([\s\S]*?)\{\{\/each\}\}/g,
103
+ (_, arrayPath, inner) => {
104
+ const arr = resolveTokenPath(renderData, arrayPath.trim());
105
+ if (!Array.isArray(arr)) return '';
106
+ return arr.map(item => inner.replace(/\{\{this\}\}/g, item)).join('\n');
107
+ }
108
+ );
109
+
110
+ // Replace remaining {{path}} tokens
111
+ template = template.replace(/\{\{([^#/][^}]*)\}\}/g, (_, path) => {
112
+ const val = resolveTokenPath(renderData, path.trim());
113
+ return val !== undefined && val !== '' ? val : '';
114
+ });
115
+
116
+ // Fix headline newlines
117
+ template = template.replace(
118
+ /(<div class="bd-headline">)([\s\S]*?)(<\/div>)/,
119
+ (_, open, content, close) => open + content.replace(/\n/g, '<br>') + close
120
+ );
121
+
122
+ // Inject shader, tokens, and tuner
123
+ const shaderJS = readFileSync(resolve(THEME_DIR, 'shaders', 'bluedither-shader.js'), 'utf-8');
124
+ const shaderBundle = readFileSync(resolve(THEME_DIR, 'shaders', 'paper-shaders-bundle.js'), 'utf-8');
125
+ const tunerCSS = readFileSync(resolve(__dirname, 'tuner.css'), 'utf-8');
126
+ const tunerJS = readFileSync(resolve(__dirname, 'tuner.js'), 'utf-8');
127
+
128
+ const injection = `
129
+ <script>
130
+ window.__BD_TOKENS__ = ${JSON.stringify(tokens, null, 2)};
131
+ window.__BD_DEFAULTS__ = ${JSON.stringify(loadDefaults(), null, 2)};
132
+ </script>
133
+ <script type="module">
134
+ // Paper shaders bundle (inlined)
135
+ ${shaderBundle}
136
+
137
+ // BlueDither shader module (inlined, imports resolved above)
138
+ const shapeEnum = { simplex: 1, warp: 2, dots: 3, wave: 4, ripple: 5, swirl: 6, sphere: 7 };
139
+ const typeEnum = { random: 1, '2x2': 2, '4x4': 3, '8x8': 4 };
140
+
141
+ const parentEl = document.getElementById('bd-shader-parent');
142
+ const tokens = window.__BD_TOKENS__;
143
+
144
+ const uniforms = {
145
+ u_colorFront: getShaderColorFromString(tokens.colors.shaderFront),
146
+ u_colorBack: getShaderColorFromString(tokens.colors.shaderBack),
147
+ u_shape: shapeEnum[tokens.shader.shape] || 2,
148
+ u_type: typeEnum[tokens.shader.type] || 2,
149
+ u_pxSize: tokens.shader.size,
150
+ u_scale: tokens.shader.scale,
151
+ u_rotation: 0,
152
+ u_originX: 0.5,
153
+ u_originY: 0.5,
154
+ u_worldWidth: 0,
155
+ u_worldHeight: 0,
156
+ u_fit: 0,
157
+ u_offsetX: 0,
158
+ u_offsetY: 0,
159
+ };
160
+
161
+ const mount = new ShaderMount(
162
+ parentEl,
163
+ ditheringFragmentShader,
164
+ uniforms,
165
+ { alpha: true, premultipliedAlpha: false },
166
+ tokens.shader.speed,
167
+ 0,
168
+ 2,
169
+ );
170
+
171
+ // Expose for tuner
172
+ window.__BD_SHADER__ = {
173
+ updateParams(p) {
174
+ const u = {};
175
+ if (p.colorFront !== undefined) u.u_colorFront = getShaderColorFromString(p.colorFront);
176
+ if (p.colorBack !== undefined) u.u_colorBack = getShaderColorFromString(p.colorBack);
177
+ if (p.shape !== undefined) u.u_shape = shapeEnum[p.shape] || 2;
178
+ if (p.type !== undefined) u.u_type = typeEnum[p.type] || 2;
179
+ if (p.size !== undefined) u.u_pxSize = p.size;
180
+ if (p.scale !== undefined) u.u_scale = p.scale;
181
+ if (p.speed !== undefined) mount.setSpeed(p.speed);
182
+ if (Object.keys(u).length > 0) mount.setUniforms(u);
183
+ },
184
+ destroy() { mount.dispose(); }
185
+ };
186
+ </script>
187
+ <style>${tunerCSS}</style>
188
+ <script type="module">${tunerJS}</script>
189
+ `;
190
+
191
+ template = template.replace('</body>', injection + '\n</body>');
192
+ return template;
193
+ }
194
+
195
+ function loadDefaults() {
196
+ // Store the original shipped tokens as defaults for reset
197
+ const defaultsPath = resolve(THEME_DIR, 'tokens.defaults.json');
198
+ try {
199
+ return JSON.parse(readFileSync(defaultsPath, 'utf-8'));
200
+ } catch {
201
+ // If no defaults file, current tokens ARE the defaults
202
+ return loadTokens();
203
+ }
204
+ }
205
+
206
+ const MIME = {
207
+ '.html': 'text/html',
208
+ '.js': 'application/javascript',
209
+ '.css': 'text/css',
210
+ '.json': 'application/json',
211
+ '.png': 'image/png',
212
+ '.jpg': 'image/jpeg',
213
+ '.svg': 'image/svg+xml',
214
+ '.woff2': 'font/woff2',
215
+ };
216
+
217
+ const server = createServer((req, res) => {
218
+ res.setHeader('Access-Control-Allow-Origin', '*');
219
+ res.setHeader('Access-Control-Allow-Methods', 'GET, POST, OPTIONS');
220
+ res.setHeader('Access-Control-Allow-Headers', 'Content-Type');
221
+
222
+ if (req.method === 'OPTIONS') {
223
+ res.writeHead(204);
224
+ res.end();
225
+ return;
226
+ }
227
+
228
+ if (req.method === 'POST' && req.url === '/commit') {
229
+ let body = '';
230
+ req.on('data', chunk => { body += chunk; });
231
+ req.on('end', () => {
232
+ try {
233
+ const updatedTokens = JSON.parse(body);
234
+ writeFileSync(TOKENS_PATH, JSON.stringify(updatedTokens, null, 2) + '\n', 'utf-8');
235
+ res.writeHead(200, { 'Content-Type': 'application/json' });
236
+ res.end(JSON.stringify({ ok: true, message: 'Tokens committed to disk.' }));
237
+ console.log('[BlueDither] Tokens committed successfully.');
238
+ } catch (e) {
239
+ res.writeHead(400, { 'Content-Type': 'application/json' });
240
+ res.end(JSON.stringify({ ok: false, error: e.message }));
241
+ }
242
+ });
243
+ return;
244
+ }
245
+
246
+ if (req.method === 'GET' && req.url === '/tokens') {
247
+ const tokens = loadTokens();
248
+ res.writeHead(200, { 'Content-Type': 'application/json' });
249
+ res.end(JSON.stringify(tokens));
250
+ return;
251
+ }
252
+
253
+ if (req.method === 'GET' && (req.url === '/' || req.url === '/index.html')) {
254
+ try {
255
+ const tokens = loadTokens();
256
+ const page = renderFullPage(tokens);
257
+ res.writeHead(200, { 'Content-Type': 'text/html' });
258
+ res.end(page);
259
+ } catch (e) {
260
+ res.writeHead(500, { 'Content-Type': 'text/plain' });
261
+ res.end('Server error: ' + e.message + '\n' + e.stack);
262
+ }
263
+ return;
264
+ }
265
+
266
+ // Static files from assets
267
+ const filePath = resolve(THEME_DIR, 'assets', req.url.slice(1));
268
+ try {
269
+ const data = readFileSync(filePath);
270
+ const ext = extname(filePath);
271
+ res.writeHead(200, { 'Content-Type': MIME[ext] || 'application/octet-stream' });
272
+ res.end(data);
273
+ } catch {
274
+ res.writeHead(404);
275
+ res.end('Not found');
276
+ }
277
+ });
278
+
279
+ server.listen(PORT, () => {
280
+ console.log(`\n BlueDither Fine-Tuner running at http://localhost:${PORT}\n`);
281
+ console.log(` Tokens: ${TOKENS_PATH}`);
282
+ console.log(` Edit in browser → hit Commit → tokens.json updated on disk.\n`);
283
+ });
@@ -0,0 +1,362 @@
1
+ /* =============================================
2
+ BlueDither Fine-Tuner — Overlay Panel v2
3
+ ============================================= */
4
+
5
+ #bd-tuner {
6
+ position: fixed;
7
+ top: 16px;
8
+ right: 16px;
9
+ width: 320px;
10
+ max-height: calc(100vh - 32px);
11
+ overflow-y: auto;
12
+ background: rgba(10, 10, 20, 0.92);
13
+ backdrop-filter: blur(16px);
14
+ border: 1px solid rgba(255, 255, 255, 0.08);
15
+ border-radius: 12px;
16
+ padding: 20px;
17
+ z-index: 9999;
18
+ font-family: "SF Mono", "Cascadia Code", "Consolas", monospace;
19
+ font-size: 12px;
20
+ color: #e0e0e0;
21
+ box-shadow: 0 8px 32px rgba(0, 0, 0, 0.5);
22
+ transition: transform 0.2s ease;
23
+ }
24
+
25
+ #bd-tuner.collapsed {
26
+ transform: translateX(calc(100% + 32px));
27
+ }
28
+
29
+ #bd-tuner-toggle {
30
+ position: fixed;
31
+ top: 16px;
32
+ right: 16px;
33
+ z-index: 10000;
34
+ background: rgba(10, 10, 20, 0.92);
35
+ backdrop-filter: blur(16px);
36
+ border: 1px solid rgba(255, 255, 255, 0.08);
37
+ border-radius: 8px;
38
+ color: #e0e0e0;
39
+ padding: 8px 12px;
40
+ cursor: pointer;
41
+ font-family: "SF Mono", "Cascadia Code", "Consolas", monospace;
42
+ font-size: 11px;
43
+ letter-spacing: 0.05em;
44
+ text-transform: uppercase;
45
+ display: none;
46
+ }
47
+
48
+ #bd-tuner.collapsed ~ #bd-tuner-toggle {
49
+ display: block;
50
+ }
51
+
52
+ .bd-tuner-title {
53
+ font-size: 11px;
54
+ font-weight: 600;
55
+ text-transform: uppercase;
56
+ letter-spacing: 0.1em;
57
+ color: #888;
58
+ margin-bottom: 16px;
59
+ display: flex;
60
+ align-items: center;
61
+ justify-content: space-between;
62
+ }
63
+
64
+ .bd-tuner-title button {
65
+ background: none;
66
+ border: none;
67
+ color: #666;
68
+ cursor: pointer;
69
+ font-size: 16px;
70
+ line-height: 1;
71
+ padding: 0;
72
+ }
73
+
74
+ .bd-tuner-title button:hover {
75
+ color: #fff;
76
+ }
77
+
78
+ #bd-tuner-reset {
79
+ font-size: 18px !important;
80
+ }
81
+
82
+ .bd-tuner-section {
83
+ margin-bottom: 16px;
84
+ padding-bottom: 16px;
85
+ border-bottom: 1px solid rgba(255, 255, 255, 0.06);
86
+ }
87
+
88
+ .bd-tuner-section:last-of-type {
89
+ border-bottom: none;
90
+ margin-bottom: 8px;
91
+ }
92
+
93
+ .bd-tuner-section-label {
94
+ font-size: 10px;
95
+ text-transform: uppercase;
96
+ letter-spacing: 0.12em;
97
+ color: #666;
98
+ margin-bottom: 10px;
99
+ }
100
+
101
+ /* ── Row layouts ── */
102
+
103
+ .bd-tuner-row {
104
+ display: flex;
105
+ align-items: center;
106
+ justify-content: space-between;
107
+ margin-bottom: 8px;
108
+ }
109
+
110
+ .bd-tuner-row:last-child {
111
+ margin-bottom: 0;
112
+ }
113
+
114
+ .bd-tuner-row-stacked {
115
+ flex-direction: column;
116
+ align-items: stretch;
117
+ gap: 4px;
118
+ }
119
+
120
+ .bd-tuner-row-top {
121
+ display: flex;
122
+ align-items: center;
123
+ justify-content: space-between;
124
+ }
125
+
126
+ .bd-tuner-label {
127
+ font-size: 11px;
128
+ color: #999;
129
+ flex-shrink: 0;
130
+ width: 110px;
131
+ }
132
+
133
+ .bd-tuner-value {
134
+ font-size: 10px;
135
+ color: #666;
136
+ text-align: right;
137
+ flex-shrink: 0;
138
+ }
139
+
140
+ .bd-tuner-input {
141
+ flex: 1;
142
+ min-width: 0;
143
+ }
144
+
145
+ /* ── Color input ── */
146
+
147
+ .bd-tuner-input input[type="color"] {
148
+ width: 32px;
149
+ height: 24px;
150
+ border: 1px solid rgba(255, 255, 255, 0.1);
151
+ border-radius: 4px;
152
+ cursor: pointer;
153
+ background: none;
154
+ padding: 0;
155
+ }
156
+
157
+ /* ── Slider ── */
158
+
159
+ .bd-tuner-slider {
160
+ width: 100%;
161
+ height: 4px;
162
+ -webkit-appearance: none;
163
+ appearance: none;
164
+ background: rgba(255, 255, 255, 0.1);
165
+ border-radius: 2px;
166
+ outline: none;
167
+ }
168
+
169
+ .bd-tuner-slider::-webkit-slider-thumb {
170
+ -webkit-appearance: none;
171
+ width: 12px;
172
+ height: 12px;
173
+ border-radius: 50%;
174
+ background: #fff;
175
+ cursor: pointer;
176
+ }
177
+
178
+ /* ── Px input with number + unit ── */
179
+
180
+ .bd-tuner-px-input-wrap {
181
+ display: flex;
182
+ align-items: center;
183
+ gap: 2px;
184
+ }
185
+
186
+ .bd-tuner-px-num {
187
+ width: 56px;
188
+ background: rgba(255, 255, 255, 0.06);
189
+ border: 1px solid rgba(255, 255, 255, 0.1);
190
+ border-radius: 4px;
191
+ color: #e0e0e0;
192
+ padding: 3px 6px;
193
+ font-family: inherit;
194
+ font-size: 11px;
195
+ outline: none;
196
+ text-align: right;
197
+ -moz-appearance: textfield;
198
+ }
199
+
200
+ .bd-tuner-px-num::-webkit-inner-spin-button,
201
+ .bd-tuner-px-num::-webkit-outer-spin-button {
202
+ -webkit-appearance: none;
203
+ margin: 0;
204
+ }
205
+
206
+ .bd-tuner-px-num:focus {
207
+ border-color: rgba(51, 0, 255, 0.5);
208
+ }
209
+
210
+ .bd-tuner-unit {
211
+ font-size: 10px;
212
+ color: #555;
213
+ width: 16px;
214
+ }
215
+
216
+ /* ── Font dropdown ── */
217
+
218
+ .bd-tuner-font-input {
219
+ position: relative;
220
+ }
221
+
222
+ .bd-tuner-font-search {
223
+ width: 100%;
224
+ background: rgba(255, 255, 255, 0.06);
225
+ border: 1px solid rgba(255, 255, 255, 0.1);
226
+ border-radius: 4px;
227
+ color: #e0e0e0;
228
+ padding: 5px 8px;
229
+ font-family: inherit;
230
+ font-size: 11px;
231
+ outline: none;
232
+ }
233
+
234
+ .bd-tuner-font-search:focus {
235
+ border-color: rgba(51, 0, 255, 0.5);
236
+ }
237
+
238
+ .bd-tuner-font-dropdown {
239
+ display: none;
240
+ position: absolute;
241
+ top: 100%;
242
+ left: 0;
243
+ right: 0;
244
+ max-height: 200px;
245
+ overflow-y: auto;
246
+ background: rgba(15, 15, 25, 0.98);
247
+ border: 1px solid rgba(255, 255, 255, 0.1);
248
+ border-radius: 4px;
249
+ margin-top: 2px;
250
+ z-index: 100;
251
+ }
252
+
253
+ .bd-tuner-font-dropdown.open {
254
+ display: block;
255
+ }
256
+
257
+ .bd-tuner-font-option {
258
+ padding: 6px 8px;
259
+ font-size: 13px;
260
+ cursor: pointer;
261
+ white-space: nowrap;
262
+ overflow: hidden;
263
+ text-overflow: ellipsis;
264
+ }
265
+
266
+ .bd-tuner-font-option:hover {
267
+ background: rgba(51, 0, 255, 0.3);
268
+ }
269
+
270
+ .bd-tuner-font-dropdown::-webkit-scrollbar {
271
+ width: 4px;
272
+ }
273
+
274
+ .bd-tuner-font-dropdown::-webkit-scrollbar-thumb {
275
+ background: rgba(255, 255, 255, 0.1);
276
+ border-radius: 2px;
277
+ }
278
+
279
+ /* ── Segmented buttons ── */
280
+
281
+ .bd-tuner-segmented {
282
+ display: flex;
283
+ gap: 1px;
284
+ background: rgba(255, 255, 255, 0.06);
285
+ border-radius: 4px;
286
+ overflow: hidden;
287
+ margin-top: 4px;
288
+ }
289
+
290
+ .bd-tuner-seg-btn {
291
+ flex: 1;
292
+ padding: 5px 4px;
293
+ background: transparent;
294
+ border: none;
295
+ color: #888;
296
+ font-family: inherit;
297
+ font-size: 10px;
298
+ cursor: pointer;
299
+ text-transform: capitalize;
300
+ transition: background 0.1s, color 0.1s;
301
+ }
302
+
303
+ .bd-tuner-seg-btn:hover {
304
+ background: rgba(255, 255, 255, 0.06);
305
+ color: #ccc;
306
+ }
307
+
308
+ .bd-tuner-seg-btn.active {
309
+ background: rgba(51, 0, 255, 0.5);
310
+ color: #fff;
311
+ }
312
+
313
+ /* ── Text inputs ── */
314
+
315
+ .bd-tuner-input input[type="text"],
316
+ .bd-tuner-input input[type="number"] {
317
+ width: 100%;
318
+ background: rgba(255, 255, 255, 0.06);
319
+ border: 1px solid rgba(255, 255, 255, 0.1);
320
+ border-radius: 4px;
321
+ color: #e0e0e0;
322
+ padding: 4px 8px;
323
+ font-family: inherit;
324
+ font-size: 11px;
325
+ outline: none;
326
+ }
327
+
328
+ .bd-tuner-input input[type="text"]:focus,
329
+ .bd-tuner-input input[type="number"]:focus {
330
+ border-color: rgba(51, 0, 255, 0.5);
331
+ }
332
+
333
+ /* ── Commit button ── */
334
+
335
+ #bd-tuner-commit {
336
+ width: 100%;
337
+ padding: 10px;
338
+ background: #3300FF;
339
+ color: #fff;
340
+ border: none;
341
+ border-radius: 6px;
342
+ font-family: inherit;
343
+ font-size: 12px;
344
+ font-weight: 600;
345
+ text-transform: uppercase;
346
+ letter-spacing: 0.08em;
347
+ cursor: pointer;
348
+ transition: background 0.15s ease;
349
+ margin-top: 8px;
350
+ }
351
+
352
+ #bd-tuner-commit:hover { background: #4400FF; }
353
+ #bd-tuner-commit:active { background: #2200CC; }
354
+ #bd-tuner-commit.success { background: #0a7; }
355
+
356
+ /* ── Scrollbar ── */
357
+
358
+ #bd-tuner::-webkit-scrollbar { width: 4px; }
359
+ #bd-tuner::-webkit-scrollbar-thumb {
360
+ background: rgba(255, 255, 255, 0.1);
361
+ border-radius: 2px;
362
+ }