draply-dev 1.4.6 → 1.5.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.
Files changed (3) hide show
  1. package/bin/cli.js +137 -45
  2. package/package.json +1 -1
  3. package/src/overlay.js +290 -45
package/bin/cli.js CHANGED
@@ -7,11 +7,32 @@ const path = require('path');
7
7
  const zlib = require('zlib');
8
8
 
9
9
  const args = process.argv.slice(2);
10
- const targetPort = parseInt(args[0]) || 3000;
11
- const proxyPort = parseInt(args[1]) || 4000;
12
- const targetHost = args[2] || 'localhost';
13
-
14
10
  const pkg = require('../package.json');
11
+
12
+ // --help / --version (#18)
13
+ if (args.includes('--help') || args.includes('-h')) {
14
+ console.log(`\n \x1b[36mDraply v${pkg.version}\x1b[0m — Visual overlay for any frontend project\n`);
15
+ console.log(` Usage: npx draply <target-port> [proxy-port] [target-host]\n`);
16
+ console.log(` Arguments:`);
17
+ console.log(` target-port Port your dev server runs on (default: 3000)`);
18
+ console.log(` proxy-port Port for Draply proxy (default: 4000)`);
19
+ console.log(` target-host Hostname of dev server (default: localhost)\n`);
20
+ console.log(` Examples:`);
21
+ console.log(` npx draply 3000 Proxy localhost:3000 → localhost:4000`);
22
+ console.log(` npx draply 5500 4001 Proxy localhost:5500 → localhost:4001\n`);
23
+ console.log(` Options:`);
24
+ console.log(` --help, -h Show this help`);
25
+ console.log(` --version, -v Show version\n`);
26
+ process.exit(0);
27
+ }
28
+ if (args.includes('--version') || args.includes('-v')) {
29
+ console.log(pkg.version);
30
+ process.exit(0);
31
+ }
32
+
33
+ const targetPort = parseInt(args.filter(a => !a.startsWith('-'))[0]) || 3000;
34
+ const proxyPort = parseInt(args.filter(a => !a.startsWith('-'))[1]) || 4000;
35
+ const targetHost = args.filter(a => !a.startsWith('-'))[2] || 'localhost';
15
36
  const OVERLAY_JS = fs.readFileSync(path.join(__dirname, '../src/overlay.js'), 'utf8');
16
37
  // Unique marker that does NOT appear inside overlay.js itself
17
38
  const MARKER = 'data-ps-done="1"';
@@ -42,6 +63,76 @@ if (!fs.existsSync(overridesPath)) {
42
63
  fs.writeFileSync(overridesPath, '/* draply */\n', 'utf8');
43
64
  }
44
65
 
66
+ // Config — stored in ~/.draply/ to avoid accidental git commits (#19)
67
+ const configDir = path.join(require('os').homedir(), '.draply');
68
+ if (!fs.existsSync(configDir)) fs.mkdirSync(configDir, { recursive: true });
69
+ const configPath = path.join(configDir, 'config.json');
70
+ function loadConfig() { try { return JSON.parse(fs.readFileSync(configPath, 'utf8')); } catch { return {}; } }
71
+ function saveConfig(cfg) { fs.writeFileSync(configPath, JSON.stringify(cfg, null, 2), 'utf8'); }
72
+
73
+ // Multi-provider AI call (#10)
74
+ async function callAI(cfg, prompt) {
75
+ const provider = cfg.provider || 'groq';
76
+ const https = require('https');
77
+
78
+ // Anthropic — different API format
79
+ if (provider === 'anthropic') {
80
+ const body = JSON.stringify({
81
+ model: cfg.model || 'claude-sonnet-4-20250514', max_tokens: 8192,
82
+ messages: [{ role: 'user', content: prompt }]
83
+ });
84
+ return new Promise((resolve, reject) => {
85
+ const req = https.request({
86
+ hostname: 'api.anthropic.com', path: '/v1/messages', method: 'POST',
87
+ headers: { 'Content-Type': 'application/json', 'x-api-key': cfg.apiKey, 'anthropic-version': '2023-06-01', 'Content-Length': Buffer.byteLength(body) }
88
+ }, r => {
89
+ const ch = []; r.on('data', c => ch.push(c));
90
+ r.on('end', () => { try { const d = JSON.parse(Buffer.concat(ch).toString()); d.error ? reject(new Error(d.error.message)) : resolve(d.content?.[0]?.text || ''); } catch(e) { reject(new Error('Invalid Anthropic response')); } });
91
+ });
92
+ req.on('error', reject); req.write(body); req.end();
93
+ });
94
+ }
95
+
96
+ // Ollama — local HTTP
97
+ if (provider === 'ollama') {
98
+ const http2 = require('http');
99
+ const body = JSON.stringify({ model: cfg.model || 'llama3', messages: [{ role: 'user', content: prompt }], stream: false });
100
+ return new Promise((resolve, reject) => {
101
+ const req = http2.request({
102
+ hostname: cfg.ollamaHost || 'localhost', port: cfg.ollamaPort || 11434, path: '/api/chat', method: 'POST',
103
+ headers: { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(body) }
104
+ }, r => {
105
+ const ch = []; r.on('data', c => ch.push(c));
106
+ r.on('end', () => { try { const d = JSON.parse(Buffer.concat(ch).toString()); resolve(d.message?.content || ''); } catch(e) { reject(new Error('Invalid Ollama response')); } });
107
+ });
108
+ req.on('error', reject); req.write(body); req.end();
109
+ });
110
+ }
111
+
112
+ // OpenAI / Groq — compatible API
113
+ const hosts = {
114
+ openai: ['api.openai.com', '/v1/chat/completions', 'gpt-4o-mini'],
115
+ groq: ['api.groq.com', '/openai/v1/chat/completions', 'llama-3.3-70b-versatile']
116
+ };
117
+ const [hostname, apiPath, defaultModel] = hosts[provider] || hosts.groq;
118
+ const body = JSON.stringify({ model: cfg.model || defaultModel, messages: [{ role: 'user', content: prompt }], temperature: 0.1, max_tokens: 8192 });
119
+ return new Promise((resolve, reject) => {
120
+ const req = https.request({
121
+ hostname, path: apiPath, method: 'POST',
122
+ headers: { 'Content-Type': 'application/json', 'Authorization': `Bearer ${cfg.apiKey}`, 'Content-Length': Buffer.byteLength(body) }
123
+ }, r => {
124
+ const ch = []; r.on('data', c => ch.push(c));
125
+ r.on('end', () => { try { const d = JSON.parse(Buffer.concat(ch).toString()); d.error ? reject(new Error(d.error?.message || JSON.stringify(d.error))) : resolve(d.choices?.[0]?.message?.content || ''); } catch(e) { reject(new Error('Invalid API response')); } });
126
+ });
127
+ req.on('error', reject); req.write(body); req.end();
128
+ });
129
+ }
130
+
131
+ // API key validation (#8)
132
+ async function validateApiKey(cfg) {
133
+ try { await callAI(cfg, 'Say "ok"'); return true; } catch { return false; }
134
+ }
135
+
45
136
 
46
137
  const server = http.createServer((req, res) => {
47
138
 
@@ -94,10 +185,7 @@ const server = http.createServer((req, res) => {
94
185
  return;
95
186
  }
96
187
 
97
- // ── Config endpoint (get/set API key) ───────────────────────────────────────
98
- const configPath = path.join(process.cwd(), 'draply.config.json');
99
- function loadConfig() { try { return JSON.parse(fs.readFileSync(configPath, 'utf8')); } catch { return {}; } }
100
- function saveConfig(cfg) { fs.writeFileSync(configPath, JSON.stringify(cfg, null, 2), 'utf8'); }
188
+ // ── Config endpoint (get/set API key) — config stored in ~/.draply/ (#19) ──
101
189
 
102
190
  if (req.url === '/draply-config') {
103
191
  if (req.method === 'GET') {
@@ -123,6 +211,24 @@ const server = http.createServer((req, res) => {
123
211
  }
124
212
  }
125
213
 
214
+ // ── Validate API key endpoint (#8) ─────────────────────────────────────────
215
+ if (req.url === '/draply-validate-key' && req.method === 'POST') {
216
+ let body = '';
217
+ req.on('data', c => body += c);
218
+ req.on('end', async () => {
219
+ try {
220
+ const { apiKey, provider } = JSON.parse(body);
221
+ const valid = await validateApiKey({ apiKey, provider: provider || 'groq' });
222
+ res.writeHead(200, { 'Content-Type': 'application/json' });
223
+ res.end(JSON.stringify({ ok: true, valid }));
224
+ } catch (e) {
225
+ res.writeHead(200, { 'Content-Type': 'application/json' });
226
+ res.end(JSON.stringify({ ok: true, valid: false, error: e.message }));
227
+ }
228
+ });
229
+ return;
230
+ }
231
+
126
232
  // ── AI Apply endpoint ───────────────────────────────────────────────────────
127
233
  if ((req.url === '/draply-ai-apply' || req.url === '/draply-save') && req.method === 'POST') {
128
234
  let body = '';
@@ -133,16 +239,22 @@ const server = http.createServer((req, res) => {
133
239
  const cfg = loadConfig();
134
240
 
135
241
  // If no config, fallback to saving standard CSS
242
+ // Always write CSS backup (#13)
243
+ const cssLines = [];
244
+ for (const ch of (changes || [])) {
245
+ if (!ch.selector || ch.type === 'create') continue;
246
+ const cProps = Object.entries(ch.props).filter(([k]) => k !== 'googleFont' && k !== 'innerHTML' && k !== 'innerText' && k !== 'src').map(([k, v]) => ` ${k}: ${v};`).join('\n');
247
+ if (cProps) {
248
+ const label = ch.selector.split('>').pop().trim();
249
+ cssLines.push(`/* ${label} */\n${ch.selector} {\n${cProps}\n}`);
250
+ }
251
+ }
252
+ if (cssLines.length) {
253
+ fs.writeFileSync(overridesPath, '/* draply — auto-generated backup */\n\n' + cssLines.join('\n\n') + '\n', 'utf8');
254
+ console.log(` \x1b[36m✓\x1b[0m CSS backup written to draply.css`);
255
+ }
256
+
136
257
  if (!cfg.apiKey) {
137
- const lines = [];
138
- for (const ch of (changes || [])) {
139
- if (!ch.selector) continue;
140
- const props = Object.entries(ch.props).map(([k, v]) => ` ${k}: ${v};`).join('\n');
141
- const label = ch.selector.split('>').pop().trim();
142
- lines.push(`/* ${label} */\n${ch.selector} {\n${props}\n}`);
143
- }
144
- const css = '/* draply */\n\n' + lines.join('\n\n') + '\n';
145
- fs.writeFileSync(overridesPath, css, 'utf8');
146
258
  res.writeHead(200, { 'Content-Type': 'application/json' });
147
259
  res.end(JSON.stringify({ ok: true, fallback: true }));
148
260
  return;
@@ -271,29 +383,8 @@ Return ONLY the patch blocks.`;
271
383
 
272
384
  let apiResult = '';
273
385
  try {
274
- // Groq call
275
- const groqBody = JSON.stringify({
276
- model: 'llama-3.3-70b-versatile',
277
- messages: [{ role: 'user', content: prompt }],
278
- temperature: 0.1, max_tokens: 8192
279
- });
280
- apiResult = await new Promise((resolve, reject) => {
281
- const https = require('https');
282
- const req = https.request({
283
- hostname: 'api.groq.com', path: '/openai/v1/chat/completions',
284
- method: 'POST',
285
- headers: { 'Content-Type': 'application/json', 'Authorization': `Bearer ${cfg.apiKey}`, 'Content-Length': Buffer.byteLength(groqBody) }
286
- }, res => {
287
- const chunks = [];
288
- res.on('data', c => chunks.push(c));
289
- res.on('end', () => {
290
- const data = JSON.parse(Buffer.concat(chunks).toString());
291
- if (data.error) reject(new Error(data.error.message));
292
- else resolve(data.choices?.[0]?.message?.content || '');
293
- });
294
- });
295
- req.on('error', reject); req.write(groqBody); req.end();
296
- });
386
+ // AI call — supports Groq/OpenAI/Anthropic/Ollama (#10)
387
+ apiResult = await callAI(cfg, prompt);
297
388
 
298
389
  const patches = [];
299
390
  const patchRegex = /<search>([\s\S]*?)<\/search>[\s\S]*?<replace>([\s\S]*?)<\/replace>/g;
@@ -335,15 +426,16 @@ Return ONLY the patch blocks.`;
335
426
  console.log(apiResult);
336
427
 
337
428
  // Forced fallback for creation if AI failed match but we have a creation request
338
- const creationItem = items.find(it => it.type === 'create');
339
- if (creationItem) {
340
- console.log("Attempting forced creation fallback...");
429
+ const creationItems = items.filter(it => it.type === 'create');
430
+ if (creationItems.length > 0) {
431
+ console.log(`Attempting forced creation fallback for ${creationItems.length} element(s)...`);
341
432
  const index = content.toLowerCase().lastIndexOf('</body>');
342
433
  if (index >= 0) {
343
- const patched = content.slice(0, index) + creationItem.outerHTML + '\n' + content.slice(index);
434
+ const insertHTML = creationItems.map(ci => ci.outerHTML).join('\n');
435
+ const patched = content.slice(0, index) + insertHTML + '\n' + content.slice(index);
344
436
  const final = rawContent.includes('\r\n') ? patched.replace(/\n/g, '\r\n') : patched;
345
437
  fs.writeFileSync(filePath, final, 'utf8');
346
- console.log(` \x1b[32m✓\x1b[0m Forced creation applied to ${path.basename(filePath)}`);
438
+ console.log(` \x1b[32m✓\x1b[0m Forced creation applied (${creationItems.length} elements) to ${path.basename(filePath)}`);
347
439
  items.forEach(item => results.push({ selector: item.selector, ok: true }));
348
440
  continue;
349
441
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "draply-dev",
3
- "version": "1.4.6",
3
+ "version": "1.5.1",
4
4
  "description": "Visual overlay for any frontend project — move, resize, restyle live in the browser, save to CSS",
5
5
  "author": "Arman",
6
6
  "type": "commonjs",
package/src/overlay.js CHANGED
@@ -478,15 +478,10 @@
478
478
  <div id="__ps_sidebar__">
479
479
  <div class="ps-hdr">
480
480
  <div class="ps-brand">
481
- <svg width="16" height="16" viewBox="0 0 20 20" fill="none">
482
- <rect x="2" y="2" width="7" height="7" rx="1.5" fill="#7fff6e"/>
483
- <rect x="11" y="2" width="7" height="7" rx="1.5" fill="#7fff6e" opacity=".55"/>
484
- <rect x="2" y="11" width="7" height="7" rx="1.5" fill="#7fff6e" opacity=".55"/>
485
- <rect x="11" y="11" width="7" height="7" rx="1.5" fill="#7fff6e" opacity=".25"/>
486
- </svg>
481
+ <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="#7fff6e" stroke-width="2.5"><rect width="18" height="18" x="3" y="3" rx="4"/></svg>
487
482
  <div>
488
483
  <div class="ps-bname">Draply</div>
489
- <div class="ps-bver">v0.2.0 — overlay</div>
484
+ <div class="ps-bver">v1.5.0</div>
490
485
  </div>
491
486
  </div>
492
487
  <button class="ps-xbtn" id="__ps_x__">✕</button>
@@ -499,7 +494,9 @@
499
494
  <div class="ps-it" id="__t_ins__"><span class="ic"><svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="m8 11 2 2 4-4"/><circle cx="11" cy="11" r="8"/><path d="m21 21-4.3-4.3"/></svg></span> Inspect <span class="ps-pro-badge">PRO</span></div>
500
495
  <div class="ps-it" id="__t_rsz__"><span class="ic"><svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M16 3h5v5"/><path d="M17 21h2a2 2 0 0 0 2-2"/><path d="M21 12v3"/><path d="m21 3-5 5"/><path d="M3 7V5a2 2 0 0 1 2-2"/><path d="m5 21 4.144-4.144a1.21 1.21 0 0 1 1.712 0L13 19"/><path d="M9 3h3"/><rect x="3" y="11" width="10" height="10" rx="1"/></svg></span> Resize <span class="ps-pro-badge">PRO</span></div>
501
496
  <div class="ps-it" id="__t_clr__"><span class="ic"><svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 22a1 1 0 0 1 0-20 10 9 0 0 1 10 9 5 5 0 0 1-5 5h-2.25a1.75 1.75 0 0 0-1.4 2.8l.3.4a1.75 1.75 0 0 1-1.4 2.8z"/><circle cx="13.5" cy="6.5" r=".5" fill="currentColor"/><circle cx="17.5" cy="10.5" r=".5" fill="currentColor"/><circle cx="6.5" cy="12.5" r=".5" fill="currentColor"/><circle cx="8.5" cy="7.5" r=".5" fill="currentColor"/></svg></span> Colors <span class="ps-pro-badge">PRO</span></div>
502
- <div class="ps-it" id="__t_typ__"><span class="ic"><svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 22h6a2 2 0 0 0 2-2V8a2.4 2.4 0 0 0-.706-1.706l-3.588-3.588A2.4 2.4 0 0 0 14 2H6a2 2 0 0 0-2 2v6"/><path d="M14 2v5a1 1 0 0 0 1 1h5"/><path d="M3 16v-1.5a.5.5 0 0 1 .5-.5h7a.5.5 0 0 1 .5.5V16"/><path d="M6 22h2"/><path d="M7 14v8"/></svg></span> Typography <span class="ps-pro-badge">PRO</span></div>
497
+ <div class="ps-it" id="__t_typ__"><span class="ic"><svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 22h6a2 2 0 0 0 2-2V8a2.4 2.4 0 0 0-.706-1.706l-3.588-3.588A2.4 2.4 0 0 0 14 2H6a2 2 0 0 0-2 2v6"/><path d="M14 2v5a1 1 0 0 0 1 1h5"/><path d="M3 16v-1.5a.5.5 0 0 1 .5-.5h7a.5.5 0 0 1 .5.5V16"/><path d="M6 22h2"/><path d="M7 14v8"/></svg></span> Typography</div>
498
+ <div class="ps-it" id="__t_lay__"><span class="ic"><svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect width="18" height="18" x="3" y="3" rx="2"/><path d="M7 8h10"/><path d="M7 12h10"/><path d="M7 16h10"/></svg></span> Layers</div>
499
+
503
500
 
504
501
  <div class="ps-it" id="__t_ast__"><span class="ic"><svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M16 5h6"/><path d="M19 2v6"/><path d="M21 11.5V19a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h7.5"/><path d="m21 15-3.086-3.086a2 2 0 0 0-2.828 0L6 21"/><circle cx="9" cy="9" r="2"/></svg></span> Assets <span class="ps-pro-badge">PRO</span></div>
505
502
 
@@ -546,9 +543,41 @@
546
543
  <input type="color" class="ps-color-native" id="__cp_bd__" value="#cccccc">
547
544
  </div>
548
545
  </div>
549
- <button class="ps-apply" id="__clr_apply__">APPLY COLORS</button>
546
+
547
+ <!-- #11: Advanced styles -->
548
+ <div class="ps-sec">Advanced</div>
549
+ <div class="ps-row">
550
+ <span class="ps-lbl">Opacity</span>
551
+ <input type="range" id="__st_opacity__" min="0" max="1" step="0.05" value="1" style="flex:1;accent-color:#7fff6e">
552
+ <span class="ps-lbl" id="__st_opacity_val__">1.0</span>
553
+ </div>
554
+ <div class="ps-row">
555
+ <span class="ps-lbl">Radius</span>
556
+ <input type="number" class="ps-inp" id="__st_radius__" min="0" max="100" value="0" style="width:50px;flex:none">
557
+ <span class="ps-lbl">px</span>
558
+ </div>
559
+ <div class="ps-row">
560
+ <span class="ps-lbl">Shadow</span>
561
+ <select class="ps-select" id="__st_shadow__">
562
+ <option value="none">None</option>
563
+ <option value="0 2px 4px rgba(0,0,0,0.1)">Soft</option>
564
+ <option value="0 4px 12px rgba(0,0,0,0.15)">Medium</option>
565
+ <option value="0 8px 24px rgba(0,0,0,0.2)">Deep</option>
566
+ </select>
567
+ </div>
568
+
569
+ <button class="ps-apply" id="__clr_apply__">APPLY STYLES</button>
570
+ </div>
571
+
572
+ <!-- #12: LAYERS PANEL -->
573
+ <div class="ps-sub" id="__sub_lay__">
574
+ <div class="ps-sec" style="padding-top:0">Elements on page</div>
575
+ <div id="__lay_list__" style="max-height:300px;overflow-y:auto;display:flex;flex-direction:column;gap:4px">
576
+ <div style="color:#444466;font-size:10px;text-align:center;padding:10px">Select an element to view tree</div>
577
+ </div>
550
578
  </div>
551
579
 
580
+
552
581
  <!-- TYPOGRAPHY SUB-PANEL -->
553
582
  <div class="ps-sub" id="__sub_typ__">
554
583
  <div class="ps-sec" style="padding-top:0">Selected: <span id="__typ_el_name__" style="color:#7fff6e">—</span></div>
@@ -771,6 +800,10 @@
771
800
  subClr.classList.toggle('v', t === 'clr');
772
801
  subTyp.classList.toggle('v', t === 'typ');
773
802
  subAst.classList.toggle('v', t === 'ast');
803
+ // #12: Layers sub-panel toggle
804
+ const subLay = document.getElementById('__sub_lay__');
805
+ if (subLay) subLay.classList.toggle('v', t === 'lay');
806
+ if (t === 'lay') updateLayers();
774
807
  toast(toolHints[t]);
775
808
  }
776
809
 
@@ -788,6 +821,9 @@
788
821
  subTyp.classList.remove('v');
789
822
  subAst.classList.remove('v');
790
823
  subUns.classList.remove('v');
824
+ const subLay = document.getElementById('__sub_lay__');
825
+ if (subLay) subLay.classList.remove('v');
826
+ }
791
827
  }
792
828
 
793
829
  // ── HOVER ────────────────────────────────────────────────────────────────
@@ -1059,11 +1095,18 @@
1059
1095
  function populateColors(el) {
1060
1096
  const cs = getComputedStyle(el);
1061
1097
  clrElName.textContent = el.tagName.toLowerCase() + (el.id ? '#' + el.id : el.className ? '.' + [...el.classList][0] : '');
1062
- const bg = cs.backgroundColor;
1063
- const fg = cs.color || '#000000';
1064
- const bd = cs.borderColor || '#cccccc';
1098
+ let bg = cs.backgroundColor;
1099
+ let fg = cs.color || '#000000';
1100
+ let bd = cs.borderColor || '#cccccc';
1065
1101
  const bw = cs.borderWidth;
1066
1102
 
1103
+ // #14: Support SVG fill/stroke (#14)
1104
+ const isSvg = el.namespaceURI === 'http://www.w3.org/2000/svg';
1105
+ if (isSvg) {
1106
+ fg = cs.fill;
1107
+ bd = cs.stroke;
1108
+ }
1109
+
1067
1110
  // Background — detect transparent
1068
1111
  if (isTransparent(bg)) {
1069
1112
  cpBgTrans.checked = true;
@@ -1087,6 +1130,27 @@
1087
1130
  swBd.style.background = cpBd.value;
1088
1131
  swBd.style.opacity = '1';
1089
1132
  }
1133
+
1134
+ // #11: Advanced styles detection
1135
+ const opVal = parseFloat(cs.opacity) || 1;
1136
+ document.getElementById('__st_opacity__').value = opVal;
1137
+ document.getElementById('__st_opacity_val__').textContent = opVal.toFixed(2);
1138
+ document.getElementById('__st_radius__').value = parseInt(cs.borderRadius) || 0;
1139
+ document.getElementById('__st_shadow__').value = cs.boxShadow === 'none' ? 'none' : '0 4px 12px rgba(0,0,0,0.15)'; // simple match
1140
+ }
1141
+
1142
+ // #11: Live preview for advanced styles
1143
+ document.getElementById('__st_opacity__').oninput = e => {
1144
+ const v = e.target.value;
1145
+ document.getElementById('__st_opacity_val__').textContent = parseFloat(v).toFixed(2);
1146
+ if (state.selectedEl) state.selectedEl.style.opacity = v;
1147
+ };
1148
+ document.getElementById('__st_radius__').oninput = e => {
1149
+ if (state.selectedEl) state.selectedEl.style.borderRadius = e.target.value + 'px';
1150
+ };
1151
+ document.getElementById('__st_shadow__').onchange = e => {
1152
+ if (state.selectedEl) state.selectedEl.style.boxShadow = e.target.value === 'none' ? '' : e.target.value;
1153
+ };
1090
1154
  }
1091
1155
 
1092
1156
  document.getElementById('__clr_apply__').onclick = () => {
@@ -1094,14 +1158,30 @@
1094
1158
  const el = state.selectedEl;
1095
1159
  const bgVal = cpBgTrans.checked ? 'transparent' : cpBg.value;
1096
1160
  const bdVal = cpBdTrans.checked ? 'none' : cpBd.value;
1097
- // Snapshot BEFORE applying
1161
+ const opVal = document.getElementById('__st_opacity__').value;
1162
+ const radVal = document.getElementById('__st_radius__').value + 'px';
1163
+ const shVal = document.getElementById('__st_shadow__').value;
1164
+
1098
1165
  const prevProps = {
1099
1166
  'background-color': el.style.backgroundColor || '',
1100
1167
  'color': el.style.color || '',
1101
- 'border': el.style.border || ''
1168
+ 'border': el.style.border || '',
1169
+ 'opacity': el.style.opacity || '',
1170
+ 'border-radius': el.style.borderRadius || '',
1171
+ 'box-shadow': el.style.boxShadow || ''
1102
1172
  };
1173
+
1174
+ if (el.namespaceURI === 'http://www.w3.org/2000/svg') {
1175
+ el.style.fill = cpFg.value;
1176
+ el.style.stroke = cpBdTrans.checked ? 'none' : bdVal;
1177
+ }
1178
+
1103
1179
  el.style.backgroundColor = bgVal;
1104
1180
  el.style.color = cpFg.value;
1181
+ el.style.opacity = opVal;
1182
+ el.style.borderRadius = radVal;
1183
+ el.style.boxShadow = shVal === 'none' ? '' : shVal;
1184
+
1105
1185
  if (cpBdTrans.checked) {
1106
1186
  el.style.border = 'none';
1107
1187
  } else {
@@ -1109,8 +1189,12 @@
1109
1189
  if (getComputedStyle(el).borderWidth === '0px') el.style.borderWidth = '1px';
1110
1190
  if (getComputedStyle(el).borderStyle === 'none') el.style.borderStyle = 'solid';
1111
1191
  }
1112
- rec(el, { 'background-color': bgVal, color: cpFg.value, 'border': cpBdTrans.checked ? 'none' : `1px solid ${bdVal}` }, prevProps);
1113
- toast('🎨 Colors applied!');
1192
+ rec(el, {
1193
+ 'background-color': bgVal, color: cpFg.value,
1194
+ 'border': cpBdTrans.checked ? 'none' : `1px solid ${bdVal}`,
1195
+ 'opacity': opVal, 'border-radius': radVal, 'box-shadow': shVal === 'none' ? '' : shVal
1196
+ }, prevProps);
1197
+ toast('🎨 Styles applied!');
1114
1198
  };
1115
1199
 
1116
1200
  function rgb2hex(rgb) {
@@ -1341,30 +1425,37 @@
1341
1425
  e.preventDefault(); e.stopPropagation();
1342
1426
  if (!pendingAsset || !placingEl) return;
1343
1427
 
1344
- if (e.shiftKey) {
1345
- // REPLACE mode
1346
- if (e.target.tagName.toLowerCase() === 'img') {
1347
- const prevSrc = e.target.getAttribute('src');
1348
- e.target.src = pendingAsset.src;
1349
- rec(e.target, { src: pendingAsset.src }, { src: prevSrc || '' });
1350
- toast('🖼️ Image source replaced!');
1428
+ if (e.shiftKey || e.altKey) {
1429
+ // #6: Better insertion targeting
1430
+ const target = e.target;
1431
+ if (target.tagName.toLowerCase() === 'img') {
1432
+ const prevSrc = target.getAttribute('src');
1433
+ target.src = pendingAsset.src;
1434
+ rec(target, { src: pendingAsset.src }, { src: prevSrc || '' });
1435
+ toast('🖼️ Image replaced!');
1351
1436
  } else {
1352
- const cs = getComputedStyle(e.target);
1353
- const prevBg = cs.backgroundImage;
1354
- e.target.style.backgroundImage = `url('${pendingAsset.src}')`;
1355
- e.target.style.backgroundSize = 'cover';
1356
- e.target.style.backgroundPosition = 'center';
1357
- rec(e.target, { backgroundImage: `url('${pendingAsset.src}')`, backgroundSize: 'cover', backgroundPosition: 'center' }, { backgroundImage: prevBg });
1358
- toast('🖼️ Background image set!');
1437
+ // Option to insert INSIDE clicked container if Alt is held
1438
+ if (e.altKey) {
1439
+ placeAsset(pendingAsset, 0, 0, 120, 120, target);
1440
+ toast('✦ Placed inside ' + target.tagName.toLowerCase());
1441
+ } else {
1442
+ const cs = getComputedStyle(target);
1443
+ const prevBg = cs.backgroundImage;
1444
+ target.style.backgroundImage = `url('${pendingAsset.src}')`;
1445
+ target.style.backgroundSize = 'cover';
1446
+ rec(target, { backgroundImage: `url('${pendingAsset.src}')`, backgroundSize: 'cover' }, { backgroundImage: prevBg });
1447
+ toast('🖼️ Background set!');
1448
+ }
1359
1449
  }
1360
1450
  } else {
1361
- // PLACE mode
1451
+ // PLACE mode (standard)
1362
1452
  const x = e.clientX - 60;
1363
1453
  const y = e.clientY - 60;
1364
1454
  placeAsset(pendingAsset, x, y, 120, 120);
1365
1455
  }
1366
1456
  cancelPlacing();
1367
1457
  }
1458
+ }
1368
1459
 
1369
1460
  function onPlacingCancel(e) {
1370
1461
  if (e.key === 'Escape') cancelPlacing();
@@ -1379,15 +1470,19 @@
1379
1470
  document.removeEventListener('keydown', onPlacingCancel);
1380
1471
  }
1381
1472
 
1382
- // Place asset permanently on page (absolute positioned)
1383
- function placeAsset(asset, x, y, w, h) {
1473
+ // #6: Place asset permanently on page (optionally inside a parent)
1474
+ function placeAsset(asset, x, y, w, h, parent = document.body) {
1384
1475
  const wrap = document.createElement('div');
1385
1476
  wrap.className = 'ps-asset-placed';
1386
1477
  const uid = 'ps-asset-' + Date.now();
1387
1478
  wrap.id = uid;
1388
- wrap.style.cssText = `left:${Math.round(x)}px;top:${Math.round(y)}px;width:${w}px;height:${h}px;z-index:1;`;
1479
+
1480
+ // If we have a specific parent, use relative positioning if needed
1481
+ const posType = parent === document.body ? 'fixed' : 'absolute';
1482
+ wrap.style.cssText = `position:${posType};left:${Math.round(x)}px;top:${Math.round(y)}px;width:${w}px;height:${h}px;z-index:1;`;
1389
1483
  wrap.innerHTML = `<img src="${asset.src}" draggable="false" alt="${asset.name}">`;
1390
- document.body.appendChild(wrap);
1484
+ parent.appendChild(wrap);
1485
+
1391
1486
 
1392
1487
  // Click to select this placed asset (for z-index control)
1393
1488
  wrap.addEventListener('click', e => {
@@ -1460,6 +1555,44 @@
1460
1555
  toast('▼ Moved to back (z:' + nz + ')');
1461
1556
  };
1462
1557
 
1558
+ // #12: LAYERS PANEL LOGIC
1559
+ function updateLayers() {
1560
+ const list = document.getElementById('__lay_list__');
1561
+ list.innerHTML = '';
1562
+
1563
+ // Find interesting elements (headers, sections, divs with classes, images)
1564
+ const items = document.querySelectorAll('body > *:not(#__ps__), section *, header *, .container *');
1565
+ const filtered = [...items].filter(el => {
1566
+ if (ps(el)) return false;
1567
+ return el.tagName !== 'SCRIPT' && el.tagName !== 'STYLE' && (el.children.length === 0 || el.id || el.className);
1568
+ }).slice(0, 50); // limit for performance
1569
+
1570
+ if (!filtered.length) {
1571
+ list.innerHTML = '<div style="color:#444;font-size:10px;text-align:center;padding:10px">No elements found</div>';
1572
+ return;
1573
+ }
1574
+
1575
+ filtered.forEach(el => {
1576
+ const row = document.createElement('div');
1577
+ row.style.cssText = 'padding:6px 8px;border-radius:4px;background:#1a1a2a;border:1px solid #2a2a3a;cursor:pointer;display:flex;align-items:center;gap:8px;font-size:10px;color:#8080a8';
1578
+ if (state.selectedEl === el) {
1579
+ row.style.borderColor = '#7fff6e';
1580
+ row.style.background = 'rgba(127,255,110,0.1)';
1581
+ row.style.color = '#7fff6e';
1582
+ }
1583
+
1584
+ const icon = el.tagName === 'IMG' ? '🖼️' : el.tagName.match(/H[1-6]/) ? 'Tt' : '📦';
1585
+ const name = el.tagName.toLowerCase() + (el.id ? '#' + el.id : el.className ? '.' + el.className.split(' ')[0] : '');
1586
+
1587
+ row.innerHTML = `<span>${icon}</span> <span style="flex:1;overflow:hidden;text-overflow:ellipsis">${name}</span>`;
1588
+ row.onclick = () => {
1589
+ select(el);
1590
+ updateLayers();
1591
+ };
1592
+ list.appendChild(row);
1593
+ });
1594
+ }
1595
+
1463
1596
  zSet.onclick = () => {
1464
1597
  if (!selectedPlaced) return;
1465
1598
  const nz = parseInt(zVal.value) || 0;
@@ -1534,7 +1667,7 @@
1534
1667
  isCreate,
1535
1668
  pixelshiftId: el.dataset.pixelshiftId || null,
1536
1669
  selector,
1537
- exactFile: getReactSource(el),
1670
+ exactFile: getReactSource(el) || window.location.pathname.replace(/^\//, '') || null, // fallback for vanilla HTML (#7)
1538
1671
  file: el.dataset.pixelshiftFile || null,
1539
1672
  props,
1540
1673
  tagName: el.tagName.toLowerCase(),
@@ -1598,12 +1731,23 @@
1598
1731
  // Remove from history
1599
1732
  const idx = history.findIndex(x => x.hid === h.hid);
1600
1733
  if (idx >= 0) history.splice(idx, 1);
1601
- // Rebuild state.changes
1734
+ // Rebuild state.changes — preserve all original fields (#1, #2)
1602
1735
  state.changes = [];
1603
1736
  history.forEach(x => {
1604
1737
  const key = x.selector;
1605
1738
  const i = state.changes.findIndex(c => (c.pixelshiftId || c.selector) === key);
1606
- if (i >= 0) Object.assign(state.changes[i].props, x.props); else state.changes.push({ type: 'css', selector: key, props: x.props });
1739
+ if (i >= 0) {
1740
+ Object.assign(state.changes[i].props, x.props);
1741
+ } else {
1742
+ state.changes.push({
1743
+ type: x.isCreate ? 'create' : 'css',
1744
+ isCreate: x.isCreate || false,
1745
+ selector: key,
1746
+ props: { ...x.props },
1747
+ outerHTML: x.isCreate && x.el ? x.el.outerHTML : undefined,
1748
+ tagName: x.el ? x.el.tagName?.toLowerCase() : undefined
1749
+ });
1750
+ }
1607
1751
  });
1608
1752
  updateUnsUI();
1609
1753
  toast('↩ Reverted');
@@ -1612,38 +1756,63 @@
1612
1756
  sv.addEventListener('click', async () => {
1613
1757
  // Check key config status
1614
1758
  let hasKey = false;
1759
+ let cfgProvider = 'groq';
1615
1760
  try {
1616
1761
  const cfgRes = await fetch('/draply-config');
1617
1762
  const cfg = await cfgRes.json();
1618
1763
  hasKey = cfg.hasKey;
1764
+ cfgProvider = cfg.provider || 'groq';
1619
1765
  } catch (e) {}
1620
1766
 
1621
1767
  if (!hasKey) {
1622
- const key = prompt('Draply AI Save: Enter your free Groq API key (from console.groq.com) to enable saving directly to React files:');
1768
+ // Ask for provider first (#8)
1769
+ const provider = prompt('Draply AI Save: Choose provider (groq / openai / anthropic / ollama):', 'groq');
1770
+ if (!provider) { toast('Save aborted'); return; }
1771
+ const key = provider === 'ollama' ? 'local' : prompt(`Enter your ${provider.toUpperCase()} API key:`);
1623
1772
  if (key) {
1624
- await fetch('/draply-config', { method: 'POST', headers:{'Content-Type':'application/json'}, body: JSON.stringify({ apiKey: key.trim(), provider: 'groq' }) });
1773
+ sv.disabled = true; sv.textContent = 'Validating...';
1774
+ try {
1775
+ const vRes = await fetch('/draply-validate-key', { method: 'POST', headers:{'Content-Type':'application/json'}, body: JSON.stringify({ apiKey: key.trim(), provider: provider.trim() }) });
1776
+ const vData = await vRes.json();
1777
+ if (!vData.valid && provider !== 'ollama') {
1778
+ toast('⚠ Invalid API key'); sv.disabled = false; sv.textContent = 'Save'; return;
1779
+ }
1780
+ } catch { /* allow through if validation endpoint unavailable */ }
1781
+ await fetch('/draply-config', { method: 'POST', headers:{'Content-Type':'application/json'}, body: JSON.stringify({ apiKey: key.trim(), provider: provider.trim() }) });
1782
+ sv.disabled = false; sv.textContent = 'Save';
1625
1783
  } else {
1626
- toast('Save aborted: AI Key required');
1784
+ toast('Save aborted: API Key required');
1627
1785
  return;
1628
1786
  }
1629
1787
  }
1630
1788
 
1789
+ // Progress indicator with animation (#9)
1631
1790
  sv.disabled = true;
1632
- sv.textContent = 'Applying...';
1791
+ sv.textContent = '';
1792
+ sv.innerHTML = '<span style="display:inline-flex;align-items:center;gap:4px"><span style="animation:spin 1s linear infinite;display:inline-block">@</span> Applying...</span>';
1793
+ // Add spin keyframe if not exists
1794
+ if (!document.getElementById('__ps_spin_style__')) {
1795
+ const spinStyle = document.createElement('style');
1796
+ spinStyle.id = '__ps_spin_style__';
1797
+ spinStyle.textContent = '@keyframes spin { from { transform: rotate(0deg); } to { transform: rotate(360deg); } }';
1798
+ document.head.appendChild(spinStyle);
1799
+ }
1633
1800
  try {
1634
1801
  const r = await fetch('/draply-ai-apply', {
1635
1802
  method: 'POST', headers: { 'Content-Type': 'application/json' },
1636
1803
  body: JSON.stringify({ changes: state.changes })
1637
1804
  });
1638
1805
  const d = await r.json();
1639
- if (d.ok) toast(d.fallback ? 'Saved to draply.css (No AI Key)' : '✅ AI Applied to Source Code!');
1640
- else toast(' Error: ' + (d.error || 'unknown'));
1806
+ if (d.ok) {
1807
+ const msg = d.fallback ? 'Saved to draply.css (No AI Key)' : `✅ AI Applied ${d.applied || ''}/${d.total || ''} to Source!`;
1808
+ toast(msg);
1809
+ } else toast('⚠ Error: ' + (d.error || 'unknown'));
1641
1810
  } catch {
1642
1811
  toast('⚠ Server unreachable');
1643
1812
  }
1644
1813
 
1645
1814
  state.changes = []; history.length = 0;
1646
- sv.textContent = 'Save';
1815
+ sv.innerHTML = 'Save'; sv.textContent = 'Save';
1647
1816
  updateUnsUI();
1648
1817
  });
1649
1818
 
@@ -1654,6 +1823,82 @@
1654
1823
 
1655
1824
  document.getElementById('__uns_save__').onclick = () => sv.click();
1656
1825
 
1826
+ // ══════════════════════════════════════════
1827
+ // KEYBOARD SHORTCUTS (#4, #5, #15)
1828
+ // ══════════════════════════════════════════
1829
+ document.addEventListener('keydown', e => {
1830
+ // Ignore if typing in an input/textarea/contenteditable
1831
+ const tag = e.target.tagName.toLowerCase();
1832
+ if (tag === 'input' || tag === 'textarea' || e.target.isContentEditable) return;
1833
+
1834
+ // Ctrl+Z — Undo last change (#4)
1835
+ if ((e.ctrlKey || e.metaKey) && !e.shiftKey && e.key === 'z') {
1836
+ e.preventDefault();
1837
+ if (history.length > 0) {
1838
+ revertChange(history[history.length - 1]);
1839
+ } else {
1840
+ toast('Nothing to undo');
1841
+ }
1842
+ return;
1843
+ }
1844
+
1845
+ // Ctrl+Shift+Z — Redo (not implemented yet, just prevent default)
1846
+ if ((e.ctrlKey || e.metaKey) && e.shiftKey && e.key === 'z') {
1847
+ e.preventDefault();
1848
+ toast('Redo not available yet');
1849
+ return;
1850
+ }
1851
+
1852
+ // Delete / Backspace — remove selected element (#5)
1853
+ if (e.key === 'Delete' || e.key === 'Backspace') {
1854
+ if (state.selectedEl && !ps(state.selectedEl)) {
1855
+ e.preventDefault();
1856
+ const el = state.selectedEl;
1857
+ // Record removal in history
1858
+ rec(el, { display: 'none' }, { display: el.style.display || '' });
1859
+ el.style.display = 'none';
1860
+ // Deselect
1861
+ el.classList.remove('__ps__', '__ps_multi__');
1862
+ state.selectedEl = null;
1863
+ state.selectedEls = [];
1864
+ hdl.classList.remove('v');
1865
+ toast('🗑 Element hidden (Delete)');
1866
+ }
1867
+ return;
1868
+ }
1869
+
1870
+ // Preview mode toggle: P key (#15)
1871
+ if (e.key === 'p' || e.key === 'P') {
1872
+ if (!e.ctrlKey && !e.metaKey) {
1873
+ e.preventDefault();
1874
+ togglePreview();
1875
+ }
1876
+ return;
1877
+ }
1878
+ });
1879
+
1880
+ // ══════════════════════════════════════════
1881
+ // PREVIEW MODE (#15)
1882
+ // ══════════════════════════════════════════
1883
+ let previewMode = false;
1884
+ function togglePreview() {
1885
+ previewMode = !previewMode;
1886
+ const psRoot = document.getElementById('__ps__');
1887
+ if (previewMode) {
1888
+ psRoot.style.display = 'none';
1889
+ // Remove all selection highlights
1890
+ document.querySelectorAll('.__ps__, .__ps_multi__, .__ph__').forEach(el => {
1891
+ el.classList.remove('__ps__', '__ps_multi__', '__ph__');
1892
+ });
1893
+ hdl.classList.remove('v');
1894
+ Object.values(rhs).forEach(h => h.classList.remove('v'));
1895
+ toast('👁 Preview mode — press P to exit');
1896
+ } else {
1897
+ psRoot.style.display = '';
1898
+ toast('Editor restored');
1899
+ }
1900
+ }
1901
+
1657
1902
  // ══════════════════════════════════════════
1658
1903
  // UTILS
1659
1904
  // ══════════════════════════════════════════