bluedither 1.0.0 → 1.0.1

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.
@@ -66,6 +66,7 @@ export default async function install(args) {
66
66
  ['theme/tokens.schema.json', 'tokens.schema.json'],
67
67
  ['theme/structure.json', 'structure.json'],
68
68
  ['theme/rules.md', 'rules.md'],
69
+ ['theme/template/index.html', 'template/index.html'],
69
70
  ['theme/shaders/bluedither-shader.js', 'shaders/bluedither-shader.js'],
70
71
  ['theme/shaders/paper-shaders-bundle.js', 'shaders/paper-shaders-bundle.js'],
71
72
  ['skill.md', 'skill.md'],
@@ -2,7 +2,9 @@
2
2
  * bluedither tune [--port N]
3
3
  *
4
4
  * Launches the fine-tuner dev server.
5
- * Finds the nearest bluedither.config.json or theme/ directory.
5
+ * Finds the nearest bluedither theme directory — either:
6
+ * - Source repo layout: ./theme/tokens.json + ./fine-tuner/server.js
7
+ * - Installed layout: ./bluedither/tokens.json (from `npx bluedither install`)
6
8
  */
7
9
 
8
10
  import { existsSync } from 'fs';
@@ -11,16 +13,7 @@ import { fileURLToPath } from 'url';
11
13
  import { spawn } from 'child_process';
12
14
 
13
15
  const __dirname = dirname(fileURLToPath(import.meta.url));
14
-
15
- function findThemeRoot(startDir) {
16
- let dir = startDir;
17
- while (dir !== dirname(dir)) {
18
- if (existsSync(resolve(dir, 'bluedither.config.json'))) return dir;
19
- if (existsSync(resolve(dir, 'theme', 'tokens.json'))) return dir;
20
- dir = dirname(dir);
21
- }
22
- return null;
23
- }
16
+ const PKG_ROOT = resolve(__dirname, '..', '..');
24
17
 
25
18
  export default async function tune(args) {
26
19
  if (args.includes('--help')) {
@@ -28,7 +21,7 @@ export default async function tune(args) {
28
21
  bluedither tune [--port N]
29
22
 
30
23
  Launches the fine-tuner dev server for real-time token editing.
31
- Searches upward for bluedither.config.json or theme/tokens.json.
24
+ Searches for a BlueDither theme in the current directory.
32
25
 
33
26
  Options:
34
27
  --port N Port number (default: 3333)
@@ -36,28 +29,55 @@ export default async function tune(args) {
36
29
  return;
37
30
  }
38
31
 
39
- const themeRoot = findThemeRoot(process.cwd());
32
+ const cwd = process.cwd();
33
+
34
+ // Determine where the theme files live
35
+ let themeDir = null;
36
+ let tokensPath = null;
40
37
 
41
- if (!themeRoot) {
38
+ // Case 1: Source repo layout (theme/ at cwd root)
39
+ if (existsSync(resolve(cwd, 'theme', 'tokens.json'))) {
40
+ themeDir = resolve(cwd, 'theme');
41
+ tokensPath = resolve(themeDir, 'tokens.json');
42
+ }
43
+ // Case 2: Installed layout (bluedither/ subfolder from `npx bluedither install`)
44
+ else if (existsSync(resolve(cwd, 'bluedither', 'tokens.json'))) {
45
+ themeDir = resolve(cwd, 'bluedither');
46
+ tokensPath = resolve(themeDir, 'tokens.json');
47
+ }
48
+ // Case 3: Search upward
49
+ else {
50
+ let dir = cwd;
51
+ while (dir !== dirname(dir)) {
52
+ if (existsSync(resolve(dir, 'theme', 'tokens.json'))) {
53
+ themeDir = resolve(dir, 'theme');
54
+ tokensPath = resolve(themeDir, 'tokens.json');
55
+ break;
56
+ }
57
+ if (existsSync(resolve(dir, 'bluedither', 'tokens.json'))) {
58
+ themeDir = resolve(dir, 'bluedither');
59
+ tokensPath = resolve(themeDir, 'tokens.json');
60
+ break;
61
+ }
62
+ dir = dirname(dir);
63
+ }
64
+ }
65
+
66
+ if (!themeDir) {
42
67
  console.error('Could not find a BlueDither theme directory.');
43
- console.error('Run this from a directory containing bluedither.config.json or theme/tokens.json.');
68
+ console.error('Run this from a project with bluedither/ or theme/ containing tokens.json.');
44
69
  process.exit(1);
45
70
  }
46
71
 
47
- const serverPath = resolve(themeRoot, 'fine-tuner', 'server.js');
48
-
72
+ // The fine-tuner server lives in the npm package
73
+ const serverPath = resolve(PKG_ROOT, 'fine-tuner', 'server.js');
49
74
  if (!existsSync(serverPath)) {
50
- // If we're in an installed theme (not the source repo), use the source repo's server
51
- const sourceServer = resolve(__dirname, '..', '..', 'fine-tuner', 'server.js');
52
- if (!existsSync(sourceServer)) {
53
- console.error('Fine-tuner server not found.');
54
- process.exit(1);
55
- }
56
- const child = spawn('node', [sourceServer, ...args], { cwd: themeRoot, stdio: 'inherit' });
57
- child.on('exit', (code) => process.exit(code || 0));
58
- return;
75
+ console.error('Fine-tuner server not found at: ' + serverPath);
76
+ process.exit(1);
59
77
  }
60
78
 
61
- const child = spawn('node', [serverPath, ...args], { cwd: themeRoot, stdio: 'inherit' });
79
+ // Pass the theme directory to the server via env var
80
+ const env = { ...process.env, BD_THEME_DIR: themeDir };
81
+ const child = spawn('node', [serverPath, ...args], { cwd: dirname(themeDir), env, stdio: 'inherit' });
62
82
  child.on('exit', (code) => process.exit(code || 0));
63
83
  }
@@ -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
+ }
@@ -0,0 +1,445 @@
1
+ /**
2
+ * BlueDither Fine-Tuner — Overlay UI v2
3
+ *
4
+ * Features:
5
+ * - Google Fonts searchable dropdown for font selection
6
+ * - Slider + arrow key support for numeric/px fields
7
+ * - Responsive clamp() computation from reference px
8
+ * - Reset button to restore shipped defaults
9
+ * - Real-time shader parameter updates
10
+ */
11
+
12
+ const tokens = structuredClone(
13
+ window.__BD_TOKENS__ ||
14
+ JSON.parse(document.querySelector('script[type="application/json"][data-bluedither-tokens]')?.textContent || '{}')
15
+ );
16
+ const defaults = structuredClone(
17
+ window.__BD_DEFAULTS__ ||
18
+ JSON.parse(document.querySelector('script[type="application/json"][data-bluedither-defaults]')?.textContent || '{}')
19
+ );
20
+
21
+ /** True when served by fine-tuner/server.js (has /commit endpoint) */
22
+ const __serverMode = !!(window.__BD_TOKENS__ && window.__BD_DEFAULTS__);
23
+
24
+ // ── System fonts (always available, no loading needed) ──
25
+ const SYSTEM_FONTS = [
26
+ 'Arial', 'Arial Black', 'Bebas Neue Pro', 'Consolas', 'Courier New',
27
+ 'Georgia', 'Helvetica', 'Impact', 'Lucida Console', 'Segoe UI',
28
+ 'Tahoma', 'Times New Roman', 'Trebuchet MS', 'Verdana',
29
+ 'SF Pro Display', 'SF Mono', 'Cascadia Code', 'Menlo', 'Monaco',
30
+ ];
31
+
32
+ // ── Google Fonts (loaded on demand) ──
33
+ const GOOGLE_FONTS = [
34
+ 'Bebas Neue', 'Space Mono', 'Inter', 'Roboto', 'Roboto Mono', 'Roboto Condensed',
35
+ 'Open Sans', 'Montserrat', 'Lato', 'Oswald', 'Raleway', 'Poppins',
36
+ 'Nunito', 'Playfair Display', 'Merriweather', 'Source Sans 3', 'Source Code Pro',
37
+ 'PT Sans', 'PT Serif', 'PT Mono', 'Ubuntu', 'Ubuntu Mono', 'Fira Sans',
38
+ 'Fira Code', 'Fira Mono', 'Work Sans', 'Noto Sans', 'Noto Serif',
39
+ 'DM Sans', 'DM Serif Display', 'DM Mono', 'IBM Plex Sans', 'IBM Plex Mono',
40
+ 'IBM Plex Serif', 'JetBrains Mono', 'Inconsolata', 'Space Grotesk',
41
+ 'Archivo', 'Archivo Black', 'Barlow', 'Barlow Condensed', 'Lexend',
42
+ 'Outfit', 'Sora', 'Manrope', 'Bitter', 'Crimson Text', 'Libre Baskerville',
43
+ 'Abril Fatface', 'Anton', 'Permanent Marker', 'Righteous', 'Orbitron',
44
+ 'Teko', 'Rubik', 'Quicksand', 'Cabin', 'Karla', 'Josefin Sans',
45
+ 'Comfortaa', 'Fredoka', 'Geologica', 'Instrument Sans', 'Instrument Serif',
46
+ ];
47
+
48
+ // Combined and deduplicated
49
+ const ALL_FONTS = [...new Set([...SYSTEM_FONTS, ...GOOGLE_FONTS])].sort();
50
+
51
+ // ── Helpers ──
52
+
53
+ function setTokenPath(obj, path, value) {
54
+ const keys = path.split('.');
55
+ let o = obj;
56
+ for (let i = 0; i < keys.length - 1; i++) o = o[keys[i]];
57
+ o[keys[keys.length - 1]] = value;
58
+ }
59
+
60
+ function getTokenPath(obj, path) {
61
+ return path.split('.').reduce((o, k) => o?.[k], obj);
62
+ }
63
+
64
+ function setCSSVar(name, value) {
65
+ document.documentElement.style.setProperty(name, value);
66
+ }
67
+
68
+ function updateShader(key, value) {
69
+ const renderer = window.__BD_SHADER__;
70
+ if (renderer) renderer.updateParams({ [key]: value });
71
+ }
72
+
73
+ function pxToClamp(px) {
74
+ const dw = tokens.layout.designWidth;
75
+ const maxRem = px / 16;
76
+ const vw = (px / dw) * 100;
77
+ const minRem = maxRem * 0.55;
78
+ return `clamp(${minRem.toFixed(4)}rem, ${vw.toFixed(4)}vw, ${maxRem.toFixed(4)}rem)`;
79
+ }
80
+
81
+ function loadGoogleFont(family) {
82
+ const id = 'bd-gf-' + family.replace(/\s+/g, '-').toLowerCase();
83
+ if (document.getElementById(id)) return;
84
+ const link = document.createElement('link');
85
+ link.id = id;
86
+ link.rel = 'stylesheet';
87
+ link.href = `https://fonts.googleapis.com/css2?family=${encodeURIComponent(family)}:wght@400;700&display=swap`;
88
+ document.head.appendChild(link);
89
+ }
90
+
91
+ // ── Build the tuner panel ──
92
+
93
+ const panel = document.createElement('div');
94
+ panel.id = 'bd-tuner';
95
+
96
+ const toggle = document.createElement('button');
97
+ toggle.id = 'bd-tuner-toggle';
98
+ toggle.textContent = 'Tuner';
99
+ toggle.onclick = () => panel.classList.remove('collapsed');
100
+
101
+ document.body.appendChild(panel);
102
+ document.body.appendChild(toggle);
103
+
104
+ // Title bar
105
+ panel.innerHTML = `
106
+ <div class="bd-tuner-title">
107
+ <span>BlueDither Tuner</span>
108
+ <div style="display:flex;gap:8px;align-items:center;">
109
+ <button id="bd-tuner-reset" title="Reset to defaults">&#8635;</button>
110
+ <button id="bd-tuner-close" title="Close">&times;</button>
111
+ </div>
112
+ </div>
113
+ `;
114
+ panel.querySelector('#bd-tuner-close').onclick = () => panel.classList.add('collapsed');
115
+ panel.querySelector('#bd-tuner-reset').onclick = () => {
116
+ if (!confirm('Reset all tokens to defaults?')) return;
117
+ Object.assign(tokens, structuredClone(defaults));
118
+ if (__serverMode) {
119
+ fetch('/commit', {
120
+ method: 'POST',
121
+ headers: { 'Content-Type': 'application/json' },
122
+ body: JSON.stringify(tokens, null, 2)
123
+ }).then(() => location.reload());
124
+ } else {
125
+ location.reload();
126
+ }
127
+ };
128
+
129
+ // ── Section builders ──
130
+
131
+ function addSection(label) {
132
+ const sec = document.createElement('div');
133
+ sec.className = 'bd-tuner-section';
134
+ sec.innerHTML = `<div class="bd-tuner-section-label">${label}</div>`;
135
+ panel.appendChild(sec);
136
+ return sec;
137
+ }
138
+
139
+ function addColor(section, label, tokenPath, cssVar, extraCb) {
140
+ const val = getTokenPath(tokens, tokenPath) || '#000000';
141
+ const row = document.createElement('div');
142
+ row.className = 'bd-tuner-row';
143
+ row.innerHTML = `
144
+ <span class="bd-tuner-label">${label}</span>
145
+ <span class="bd-tuner-input"><input type="color" value="${val.substring(0, 7)}"></span>
146
+ <span class="bd-tuner-value">${val.substring(0, 7)}</span>
147
+ `;
148
+ const input = row.querySelector('input');
149
+ const display = row.querySelector('.bd-tuner-value');
150
+ input.addEventListener('input', (e) => {
151
+ const v = e.target.value;
152
+ setTokenPath(tokens, tokenPath, v);
153
+ display.textContent = v;
154
+ if (cssVar) setCSSVar(cssVar, v);
155
+ if (extraCb) extraCb(v);
156
+ });
157
+ section.appendChild(row);
158
+
159
+ // Return a handle so linked controls can programmatically update this row
160
+ return {
161
+ setValue(v) {
162
+ setTokenPath(tokens, tokenPath, v);
163
+ input.value = v.substring(0, 7);
164
+ display.textContent = v.substring(0, 7);
165
+ if (cssVar) setCSSVar(cssVar, v);
166
+ }
167
+ };
168
+ }
169
+
170
+ /**
171
+ * Numeric px field with slider + arrow key support.
172
+ * The displayed value is in px (reference), but CSS uses clamp().
173
+ */
174
+ function addPxField(section, label, tokenPath, min, max, step, cssVars) {
175
+ const val = getTokenPath(tokens, tokenPath);
176
+ const row = document.createElement('div');
177
+ row.className = 'bd-tuner-row bd-tuner-row-stacked';
178
+ row.innerHTML = `
179
+ <div class="bd-tuner-row-top">
180
+ <span class="bd-tuner-label">${label}</span>
181
+ <div class="bd-tuner-px-input-wrap">
182
+ <input type="number" class="bd-tuner-px-num" value="${val}" min="${min}" max="${max}" step="${step}">
183
+ <span class="bd-tuner-unit">px</span>
184
+ </div>
185
+ </div>
186
+ <input type="range" class="bd-tuner-slider" min="${min}" max="${max}" step="${step}" value="${val}">
187
+ `;
188
+ const numInput = row.querySelector('.bd-tuner-px-num');
189
+ const slider = row.querySelector('.bd-tuner-slider');
190
+
191
+ function apply(v) {
192
+ v = Math.max(min, Math.min(max, v));
193
+ setTokenPath(tokens, tokenPath, v);
194
+ numInput.value = v;
195
+ slider.value = v;
196
+ if (cssVars) {
197
+ const clampVal = pxToClamp(v);
198
+ (Array.isArray(cssVars) ? cssVars : [cssVars]).forEach(cv => setCSSVar(cv, clampVal));
199
+ }
200
+ }
201
+
202
+ numInput.addEventListener('input', (e) => apply(parseFloat(e.target.value) || 0));
203
+ slider.addEventListener('input', (e) => apply(parseFloat(e.target.value)));
204
+
205
+ // Arrow keys on the number input: +/- step, shift for 10x
206
+ numInput.addEventListener('keydown', (e) => {
207
+ if (e.key === 'ArrowUp' || e.key === 'ArrowDown') {
208
+ e.preventDefault();
209
+ const mult = e.shiftKey ? 10 : 1;
210
+ const delta = (e.key === 'ArrowUp' ? step : -step) * mult;
211
+ apply(parseFloat(numInput.value) + delta);
212
+ }
213
+ });
214
+
215
+ section.appendChild(row);
216
+ }
217
+
218
+ /**
219
+ * Shader-specific range slider (not px-based).
220
+ */
221
+ function addRange(section, label, tokenPath, min, max, step, extraCb) {
222
+ const val = getTokenPath(tokens, tokenPath);
223
+ const row = document.createElement('div');
224
+ row.className = 'bd-tuner-row bd-tuner-row-stacked';
225
+ row.innerHTML = `
226
+ <div class="bd-tuner-row-top">
227
+ <span class="bd-tuner-label">${label}</span>
228
+ <span class="bd-tuner-value">${val}</span>
229
+ </div>
230
+ <input type="range" class="bd-tuner-slider" min="${min}" max="${max}" step="${step}" value="${val}">
231
+ `;
232
+ const slider = row.querySelector('.bd-tuner-slider');
233
+ const display = row.querySelector('.bd-tuner-value');
234
+ slider.addEventListener('input', (e) => {
235
+ const v = parseFloat(e.target.value);
236
+ setTokenPath(tokens, tokenPath, v);
237
+ display.textContent = v;
238
+ if (extraCb) extraCb(v);
239
+ });
240
+ section.appendChild(row);
241
+ }
242
+
243
+ /**
244
+ * Font family dropdown with Google Fonts.
245
+ */
246
+ function addFontDropdown(section, label, tokenPath, cssVar) {
247
+ const currentFont = getTokenPath(tokens, tokenPath);
248
+ const row = document.createElement('div');
249
+ row.className = 'bd-tuner-row';
250
+ row.innerHTML = `
251
+ <span class="bd-tuner-label">${label}</span>
252
+ <span class="bd-tuner-input bd-tuner-font-input">
253
+ <input type="text" class="bd-tuner-font-search" value="${currentFont}" placeholder="Search fonts...">
254
+ <div class="bd-tuner-font-dropdown"></div>
255
+ </span>
256
+ `;
257
+
258
+ const searchInput = row.querySelector('.bd-tuner-font-search');
259
+ const dropdown = row.querySelector('.bd-tuner-font-dropdown');
260
+
261
+ const isSystemFont = (f) => SYSTEM_FONTS.some(s => s.toLowerCase() === f.toLowerCase());
262
+
263
+ function populateDropdown(filter = '') {
264
+ const filtered = ALL_FONTS.filter(f =>
265
+ f.toLowerCase().includes(filter.toLowerCase())
266
+ ).slice(0, 20);
267
+
268
+ dropdown.innerHTML = filtered.map(f => {
269
+ const badge = isSystemFont(f) ? ' <span style="opacity:0.4;font-size:9px">SYSTEM</span>' : '';
270
+ return `<div class="bd-tuner-font-option" data-font="${f}" style="font-family:'${f}',system-ui">${f}${badge}</div>`;
271
+ }).join('');
272
+
273
+ // Preload only Google Fonts (system fonts are already available)
274
+ filtered.filter(f => !isSystemFont(f)).forEach(loadGoogleFont);
275
+
276
+ dropdown.querySelectorAll('.bd-tuner-font-option').forEach(opt => {
277
+ opt.addEventListener('mousedown', (e) => {
278
+ e.preventDefault();
279
+ const font = opt.dataset.font;
280
+ searchInput.value = font;
281
+ setTokenPath(tokens, tokenPath, font);
282
+ if (!isSystemFont(font)) loadGoogleFont(font);
283
+ setCSSVar(cssVar, `"${font}", system-ui, sans-serif`);
284
+ dropdown.classList.remove('open');
285
+ });
286
+ });
287
+ }
288
+
289
+ searchInput.addEventListener('focus', () => {
290
+ populateDropdown(searchInput.value);
291
+ dropdown.classList.add('open');
292
+ });
293
+
294
+ searchInput.addEventListener('input', () => {
295
+ populateDropdown(searchInput.value);
296
+ dropdown.classList.add('open');
297
+ });
298
+
299
+ searchInput.addEventListener('blur', () => {
300
+ setTimeout(() => dropdown.classList.remove('open'), 150);
301
+ });
302
+
303
+ // Also update on Enter
304
+ searchInput.addEventListener('keydown', (e) => {
305
+ if (e.key === 'Enter') {
306
+ const font = searchInput.value.trim();
307
+ if (font) {
308
+ setTokenPath(tokens, tokenPath, font);
309
+ if (!isSystemFont(font)) loadGoogleFont(font);
310
+ setCSSVar(cssVar, `"${font}", system-ui, sans-serif`);
311
+ dropdown.classList.remove('open');
312
+ searchInput.blur();
313
+ }
314
+ }
315
+ });
316
+
317
+ section.appendChild(row);
318
+ }
319
+
320
+ /**
321
+ * Segmented button control (for shader type/shape).
322
+ */
323
+ function addSegmented(section, label, tokenPath, options, extraCb) {
324
+ const val = getTokenPath(tokens, tokenPath);
325
+ const row = document.createElement('div');
326
+ row.className = 'bd-tuner-row bd-tuner-row-stacked';
327
+ row.innerHTML = `
328
+ <span class="bd-tuner-label">${label}</span>
329
+ <div class="bd-tuner-segmented">
330
+ ${options.map(o => `<button class="bd-tuner-seg-btn${o === val ? ' active' : ''}" data-val="${o}">${o}</button>`).join('')}
331
+ </div>
332
+ `;
333
+ row.querySelectorAll('.bd-tuner-seg-btn').forEach(btn => {
334
+ btn.addEventListener('click', () => {
335
+ row.querySelectorAll('.bd-tuner-seg-btn').forEach(b => b.classList.remove('active'));
336
+ btn.classList.add('active');
337
+ const v = btn.dataset.val;
338
+ setTokenPath(tokens, tokenPath, v);
339
+ if (extraCb) extraCb(v);
340
+ });
341
+ });
342
+ section.appendChild(row);
343
+ }
344
+
345
+ // ══════════════════════════════════════════════
346
+ // Build sections
347
+ // ══════════════════════════════════════════════
348
+
349
+ // ── Colors ──
350
+ const colorsSection = addSection('Colors');
351
+ addColor(colorsSection, 'Background', 'colors.background', '--bd-color-background');
352
+ // Shader Front is declared first (but appended later) so Primary can reference it
353
+ let shaderFrontHandle;
354
+ addColor(colorsSection, 'Primary', 'colors.primary', '--bd-color-primary', (v) => {
355
+ // Sync shader front color: update token, UI, and live shader
356
+ shaderFrontHandle.setValue(v);
357
+ updateShader('colorFront', v);
358
+ });
359
+ addColor(colorsSection, 'Text', 'colors.text', '--bd-color-text');
360
+ addColor(colorsSection, 'CTA Background', 'colors.ctaBackground', '--bd-color-cta-bg');
361
+ addColor(colorsSection, 'CTA Text', 'colors.ctaText', '--bd-color-cta-text');
362
+ shaderFrontHandle = addColor(colorsSection, 'Shader Front', 'colors.shaderFront', null, (v) => updateShader('colorFront', v));
363
+
364
+ // ── Typography ──
365
+ const typoSection = addSection('Typography');
366
+ addFontDropdown(typoSection, 'Primary Font', 'typography.primaryFont', '--bd-font-primary');
367
+ addFontDropdown(typoSection, 'Secondary Font', 'typography.secondaryFont', '--bd-font-secondary');
368
+ addPxField(typoSection, 'Headline Size', 'typography.headline.referencePx', 32, 300, 1, '--bd-headline-size');
369
+ addPxField(typoSection, 'Headline LH', 'typography.headline.lineHeightPx', 24, 280, 1, '--bd-headline-lh');
370
+ addPxField(typoSection, 'Sub Size', 'typography.subHeadline.referencePx', 10, 48, 1, '--bd-subheadline-size');
371
+ addPxField(typoSection, 'Sub LH', 'typography.subHeadline.lineHeightPx', 12, 80, 1, '--bd-subheadline-lh');
372
+ addPxField(typoSection, 'Logo Size', 'typography.logo.referencePx', 12, 80, 1, '--bd-logo-size');
373
+ addPxField(typoSection, 'Nav Size', 'typography.navItem.referencePx', 10, 48, 1, '--bd-nav-size');
374
+
375
+ // ── Spacing ──
376
+ const spacingSection = addSection('Spacing');
377
+ addPxField(spacingSection, 'Header Pad X', 'spacing.headerPaddingX', 0, 80, 1, '--bd-header-px');
378
+ addPxField(spacingSection, 'Header Pad Y', 'spacing.headerPaddingY', 0, 60, 1, '--bd-header-py');
379
+ addPxField(spacingSection, 'Hero Pad Top', 'spacing.heroPaddingTop', 0, 120, 1, '--bd-hero-pt');
380
+ addPxField(spacingSection, 'Hero Pad Bottom', 'spacing.heroPaddingBottom', 0, 120, 1, '--bd-hero-pb');
381
+ addPxField(spacingSection, 'Hero Pad X', 'spacing.heroPaddingX', 0, 120, 1, '--bd-hero-px');
382
+ addPxField(spacingSection, 'Nav Gap', 'spacing.navGap', 0, 100, 1, '--bd-nav-gap');
383
+ addPxField(spacingSection, 'CTA Pad X', 'spacing.ctaPaddingX', 0, 60, 1, '--bd-cta-px');
384
+ addPxField(spacingSection, 'CTA Pad Y', 'spacing.ctaPaddingY', 0, 30, 1, '--bd-cta-py');
385
+ addPxField(spacingSection, 'CTA Radius', 'spacing.ctaBorderRadius', 0, 32, 1, '--bd-cta-radius');
386
+
387
+ // ── Shader ──
388
+ const shaderSection = addSection('Shader');
389
+ addSegmented(shaderSection, 'Shape', 'shader.shape',
390
+ ['warp', 'simplex', 'dots', 'wave', 'ripple', 'swirl', 'sphere'],
391
+ (v) => updateShader('shape', v));
392
+ addSegmented(shaderSection, 'Dither Type', 'shader.type',
393
+ ['random', '2x2', '4x4', '8x8'],
394
+ (v) => updateShader('type', v));
395
+ addRange(shaderSection, 'Speed', 'shader.speed', 0, 2, 0.01, (v) => updateShader('speed', v));
396
+ addRange(shaderSection, 'Scale', 'shader.scale', 0.1, 5, 0.01, (v) => updateShader('scale', v));
397
+ addRange(shaderSection, 'Dither Size', 'shader.size', 0.5, 10, 0.1, (v) => updateShader('size', v));
398
+
399
+ // ── Opacity ──
400
+ const opacitySection = addSection('Opacity');
401
+ addRange(opacitySection, 'Nav Links', 'opacity.navLinks', 0, 1, 0.01, (v) => setCSSVar('--bd-nav-opacity', v));
402
+
403
+ // ── Commit button ──
404
+ const commitBtn = document.createElement('button');
405
+ commitBtn.id = 'bd-tuner-commit';
406
+ commitBtn.textContent = 'Commit Changes';
407
+ const commitLabel = __serverMode ? 'Commit Changes' : 'Export Tokens';
408
+ commitBtn.textContent = commitLabel;
409
+ commitBtn.onclick = async () => {
410
+ commitBtn.textContent = __serverMode ? 'Committing...' : 'Exporting...';
411
+ commitBtn.disabled = true;
412
+ try {
413
+ if (__serverMode) {
414
+ const res = await fetch('/commit', {
415
+ method: 'POST',
416
+ headers: { 'Content-Type': 'application/json' },
417
+ body: JSON.stringify(tokens, null, 2)
418
+ });
419
+ const data = await res.json();
420
+ if (!data.ok) throw new Error(data.error);
421
+ commitBtn.textContent = 'Committed!';
422
+ } else {
423
+ // Standalone: download tokens as JSON file
424
+ const blob = new Blob([JSON.stringify(tokens, null, 2)], { type: 'application/json' });
425
+ const url = URL.createObjectURL(blob);
426
+ const a = document.createElement('a');
427
+ a.href = url;
428
+ a.download = 'tokens.json';
429
+ a.click();
430
+ URL.revokeObjectURL(url);
431
+ commitBtn.textContent = 'Downloaded!';
432
+ }
433
+ commitBtn.classList.add('success');
434
+ setTimeout(() => {
435
+ commitBtn.textContent = commitLabel;
436
+ commitBtn.classList.remove('success');
437
+ commitBtn.disabled = false;
438
+ }, 2000);
439
+ } catch (e) {
440
+ commitBtn.textContent = 'Error: ' + e.message;
441
+ commitBtn.disabled = false;
442
+ setTimeout(() => { commitBtn.textContent = commitLabel; }, 3000);
443
+ }
444
+ };
445
+ panel.appendChild(commitBtn);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "bluedither",
3
- "version": "1.0.0",
3
+ "version": "1.0.1",
4
4
  "description": "A bold, dithered-shader hero theme for Claude Code — skill + fine-tuner",
5
5
  "type": "module",
6
6
  "bin": {
@@ -17,6 +17,7 @@
17
17
  "theme/",
18
18
  "cli/",
19
19
  "lib/",
20
+ "fine-tuner/",
20
21
  "skill.md",
21
22
  "bluedither.config.json"
22
23
  ],