aetherx-ui-inspector 1.1.0 → 1.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "aetherx-ui-inspector",
3
- "version": "1.1.0",
3
+ "version": "1.2.0",
4
4
  "description": "Universal dev-only UI inspector with live editing and feedback clipboard",
5
5
  "type": "module",
6
6
  "main": "index.js",
package/src/core.js CHANGED
@@ -23,6 +23,46 @@ export function init() {
23
23
  let selectedOriginals = null
24
24
  let textEditCleanup = () => {}
25
25
  let isCollapsed = false
26
+ let activeTab = 'element'
27
+
28
+ // ── Class stylesheet (for Class tab mode) ─────────────────────────────────
29
+ let classRules = {}
30
+ let classStyleEl = null
31
+
32
+ function getClassStyle() {
33
+ if (!classStyleEl) {
34
+ classStyleEl = document.createElement('style')
35
+ classStyleEl.id = '__dev_inspector_class_rules__'
36
+ document.head.appendChild(classStyleEl)
37
+ }
38
+ return classStyleEl
39
+ }
40
+
41
+ function setClassRule(cls, prop, val) {
42
+ if (!classRules[cls]) classRules[cls] = {}
43
+ classRules[cls][prop] = val
44
+ rebuildClassSheet()
45
+ }
46
+
47
+ function removeClassRule(cls, prop) {
48
+ if (!classRules[cls]) return
49
+ delete classRules[cls][prop]
50
+ if (!Object.keys(classRules[cls]).length) delete classRules[cls]
51
+ rebuildClassSheet()
52
+ }
53
+
54
+ function rebuildClassSheet() {
55
+ getClassStyle().textContent = Object.entries(classRules)
56
+ .map(([cls, props]) => {
57
+ const rules = Object.entries(props).map(([p, v]) => ` ${p}: ${v} !important;`).join('\n')
58
+ return `.${cls} {\n${rules}\n}`
59
+ }).join('\n')
60
+ }
61
+
62
+ function getSelectedClass(el) {
63
+ if (!el) return null
64
+ return [...el.classList].find(c => !c.startsWith('di-') && !c.startsWith('__dev')) || null
65
+ }
26
66
 
27
67
  // ── Body margin sync (panel pushes page instead of covering it) ────────────
28
68
  document.body.style.transition = 'margin-right 0.22s cubic-bezier(.4,0,.2,1)'
@@ -36,12 +76,24 @@ export function init() {
36
76
  document.body.style.marginRight = width + 'px'
37
77
  }
38
78
 
79
+ // Animates overlay position updates over `ms` to cover CSS transition duration
80
+ function animateOverlayUpdate(ms = 260) {
81
+ if (!selectedEl) return
82
+ const end = performance.now() + ms
83
+ const tick = () => {
84
+ if (selectedEl) { updateOverlay(overlay, selectedEl); updateBoxModel(bm, selectedEl) }
85
+ if (performance.now() < end) requestAnimationFrame(tick)
86
+ }
87
+ requestAnimationFrame(tick)
88
+ }
89
+
39
90
  // ── State helpers ──────────────────────────────────────────────────────────
40
91
 
41
92
  function openPanel() {
42
93
  panelOpen = true
43
94
  panel.classList.add('open')
44
95
  syncBodyMargin()
96
+ animateOverlayUpdate()
45
97
  }
46
98
 
47
99
  function closePanel() {
@@ -49,6 +101,7 @@ export function init() {
49
101
  panel.classList.remove('open')
50
102
  selectedEl = null
51
103
  selectedOriginals = null
104
+ activeTab = 'element'
52
105
  overlay.style.display = 'none'
53
106
  hoverOverlay.style.display = 'none'
54
107
  hideSpacing(spacingContainer)
@@ -89,6 +142,7 @@ export function init() {
89
142
 
90
143
  function selectElement(el) {
91
144
  textEditCleanup()
145
+ activeTab = 'element'
92
146
  hoverOverlay.style.display = 'none'
93
147
  hideSpacing(spacingContainer)
94
148
 
@@ -123,6 +177,7 @@ export function init() {
123
177
  if (!el) {
124
178
  bar.style.display = 'none'
125
179
  label.textContent = ''
180
+ updateTabRow(null)
126
181
  return
127
182
  }
128
183
  const tag = el.tagName.toLowerCase()
@@ -134,6 +189,21 @@ export function init() {
134
189
  .join('')
135
190
  label.textContent = `${tag}${id}${cls}`
136
191
  bar.style.display = 'flex'
192
+ updateTabRow(el)
193
+ }
194
+
195
+ function updateTabRow(el) {
196
+ const tabRow = document.getElementById('di-tab-row')
197
+ const classTab = document.getElementById('di-tab-class')
198
+ const classLabel = document.getElementById('di-tab-class-label')
199
+ if (!tabRow) return
200
+ if (!el) { tabRow.style.display = 'none'; return }
201
+ tabRow.style.display = 'flex'
202
+ const cls = getSelectedClass(el)
203
+ if (classLabel) classLabel.textContent = cls ? `.${cls}` : 'No class'
204
+ if (classTab) classTab.disabled = !cls
205
+ document.getElementById('di-tab-element')?.classList.toggle('active', activeTab === 'element')
206
+ classTab?.classList.toggle('active', activeTab === 'class')
137
207
  }
138
208
 
139
209
  function renderPanel(el) {
@@ -156,6 +226,15 @@ export function init() {
156
226
  if (footer) footer.style.display = 'flex'
157
227
  updateFeedbackCount()
158
228
 
229
+ const cls = getSelectedClass(el)
230
+ const isClassMode = activeTab === 'class' && !!cls
231
+ const applyStyle = isClassMode
232
+ ? (prop, val) => setClassRule(cls, prop, val)
233
+ : (prop, val) => el.style.setProperty(prop, val)
234
+ const clearStyle = isClassMode
235
+ ? (prop) => removeClassRule(cls, prop)
236
+ : (prop) => el.style.removeProperty(prop)
237
+
159
238
  textEditCleanup = renderEditPanel(
160
239
  content,
161
240
  el,
@@ -171,7 +250,8 @@ export function init() {
171
250
  tracker.remove(el, prop)
172
251
  updateFeedbackCount()
173
252
  if (selectedEl) { updateOverlay(overlay, selectedEl); updateBoxModel(bm, selectedEl) }
174
- }
253
+ },
254
+ { applyStyle, clearStyle }
175
255
  )
176
256
  }
177
257
 
@@ -251,11 +331,27 @@ export function init() {
251
331
  }
252
332
  })
253
333
 
334
+ // ── Mode tabs ──────────────────────────────────────────────────────────────
335
+ document.getElementById('di-tab-element')?.addEventListener('click', () => {
336
+ if (activeTab === 'element') return
337
+ activeTab = 'element'
338
+ updateTabRow(selectedEl)
339
+ if (selectedEl) renderPanel(selectedEl)
340
+ })
341
+
342
+ document.getElementById('di-tab-class')?.addEventListener('click', () => {
343
+ if (activeTab === 'class' || !getSelectedClass(selectedEl)) return
344
+ activeTab = 'class'
345
+ updateTabRow(selectedEl)
346
+ if (selectedEl) renderPanel(selectedEl)
347
+ })
348
+
254
349
  // ── Collapse toggle ────────────────────────────────────────────────────────
255
350
  document.getElementById('di-collapse-btn')?.addEventListener('click', () => {
256
351
  isCollapsed = !isCollapsed
257
352
  panel.classList.toggle('collapsed', isCollapsed)
258
353
  syncBodyMargin()
354
+ animateOverlayUpdate()
259
355
  })
260
356
 
261
357
  // ── Resize handle (drag left edge to resize panel width) ───────────────────
@@ -281,6 +377,7 @@ export function init() {
281
377
  const newWidth = Math.min(580, Math.max(220, resizeStartWidth + dx))
282
378
  panel.style.width = newWidth + 'px'
283
379
  syncBodyMargin()
380
+ if (selectedEl) { updateOverlay(overlay, selectedEl); updateBoxModel(bm, selectedEl) }
284
381
  })
285
382
 
286
383
  document.addEventListener('mouseup', () => {
@@ -357,23 +454,35 @@ export function init() {
357
454
  renderTooltip(tooltip, styles, e.clientX, e.clientY)
358
455
  })
359
456
 
360
- // ── Hover while selected: spacing measurements ────────────────────────────
457
+ // ── Hover while selected: spacing + tooltip on self ──────────────────────
361
458
  document.addEventListener('mousemove', (e) => {
362
459
  if (inspecting || !selectedEl) {
363
460
  if (hoverOverlay.style.display !== 'none') {
364
461
  hoverOverlay.style.display = 'none'
365
462
  hideSpacing(spacingContainer)
366
463
  }
464
+ tooltip.style.display = 'none'
367
465
  return
368
466
  }
369
467
  const el = e.target
370
- if (isInspectorElement(el) || el === selectedEl) {
468
+ if (isInspectorElement(el)) {
371
469
  hoverOverlay.style.display = 'none'
372
470
  hideSpacing(spacingContainer)
471
+ tooltip.style.display = 'none'
373
472
  return
374
473
  }
474
+ if (el === selectedEl) {
475
+ // Hovering the selected element itself — show its tooltip, no spacing lines
476
+ hoverOverlay.style.display = 'none'
477
+ hideSpacing(spacingContainer)
478
+ const styles = getElementStyles(el)
479
+ renderTooltip(tooltip, styles, e.clientX, e.clientY)
480
+ return
481
+ }
482
+ // Hovering a different element — show spacing measurements, hide tooltip
375
483
  updateHoverOverlay(hoverOverlay, el)
376
484
  updateSpacing(spacingContainer, selectedEl, el)
485
+ tooltip.style.display = 'none'
377
486
  })
378
487
 
379
488
  // ── Click: select element (works whenever panel is open OR inspect is active)
@@ -386,12 +495,25 @@ export function init() {
386
495
  selectElement(el)
387
496
  }, true)
388
497
 
389
- // ── Escape key ─────────────────────────────────────────────────────────────
498
+ // ── Keyboard shortcuts ─────────────────────────────────────────────────────
390
499
  document.addEventListener('keydown', (e) => {
500
+ // Skip if user is typing inside an input / textarea / contenteditable
501
+ const tag = document.activeElement?.tagName.toLowerCase()
502
+ const isTyping = ['input', 'textarea', 'select'].includes(tag)
503
+ || document.activeElement?.isContentEditable
504
+
391
505
  if (e.key === 'Escape') {
392
506
  if (inspecting) stopInspect()
393
507
  else if (panelOpen) closePanel()
394
508
  }
509
+
510
+ // i — toggle inspect mode while panel is open
511
+ if ((e.key === 'i' || e.key === 'I') && !isTyping && !e.metaKey && !e.ctrlKey && !e.altKey) {
512
+ if (panelOpen) {
513
+ if (inspecting) stopInspect()
514
+ else startInspect()
515
+ }
516
+ }
395
517
  })
396
518
 
397
519
  // ── Fix 1: scroll — update overlay using fresh getBoundingClientRect ────────
@@ -406,9 +528,10 @@ export function init() {
406
528
  tooltip.style.display = 'none'
407
529
  hideBoxModel(bm)
408
530
  }
409
- // Clear hover overlay and spacing on scroll (positions are stale)
531
+ // Clear hover overlay, spacing, and tooltip on scroll (positions are stale)
410
532
  hoverOverlay.style.display = 'none'
411
533
  hideSpacing(spacingContainer)
534
+ tooltip.style.display = 'none'
412
535
  }, true)
413
536
 
414
537
  return {
@@ -421,6 +544,9 @@ export function init() {
421
544
  bm.remove()
422
545
  hoverOverlay.remove()
423
546
  spacingContainer.remove()
547
+ classStyleEl?.remove()
548
+ classStyleEl = null
549
+ classRules = {}
424
550
  document.getElementById('__dev_inspector_styles__')?.remove()
425
551
  document.body.classList.remove('di-inspect-mode')
426
552
  document.body.style.marginRight = ''
package/src/editor.js CHANGED
@@ -3,11 +3,14 @@ import { getElementStyles } from './inspector.js'
3
3
 
4
4
  const ALL_PROPS = [
5
5
  'color', 'background-color', 'font-size', 'font-weight', 'line-height',
6
- 'text-align', 'padding-top', 'padding-right', 'padding-bottom', 'padding-left',
6
+ 'font-style', 'text-align', 'text-decoration', 'text-transform', 'letter-spacing',
7
+ 'padding-top', 'padding-right', 'padding-bottom', 'padding-left',
7
8
  'margin-top', 'margin-right', 'margin-bottom', 'margin-left',
9
+ 'width', 'height', 'min-width', 'max-width', 'min-height', 'max-height',
8
10
  'border-radius', 'border-width', 'border-style', 'border-color',
9
- 'display', 'flex-direction', 'justify-content', 'align-items', 'gap',
10
- 'width', 'height', 'opacity',
11
+ 'position', 'top', 'right', 'bottom', 'left', 'z-index',
12
+ 'display', 'flex-direction', 'justify-content', 'align-items', 'gap', 'overflow',
13
+ 'opacity', 'box-shadow', 'object-fit',
11
14
  ]
12
15
 
13
16
  export function captureOriginals(el) {
@@ -23,7 +26,9 @@ export function captureOriginals(el) {
23
26
  return map
24
27
  }
25
28
 
26
- export function renderEditPanel(panelContent, el, originals, onChange, onReset) {
29
+ export function renderEditPanel(panelContent, el, originals, onChange, onReset, { applyStyle, clearStyle } = {}) {
30
+ const _apply = applyStyle || ((p, v) => el.style.setProperty(p, v))
31
+ const _clear = clearStyle || ((p) => el.style.removeProperty(p))
27
32
  const styles = getElementStyles(el)
28
33
  const { typography, background, spacing, border, layout, dimensions, opacity } = styles
29
34
  const cs = window.getComputedStyle(el)
@@ -86,6 +91,20 @@ export function renderEditPanel(panelContent, el, originals, onChange, onReset)
86
91
  ${resetBtn(prop)}
87
92
  </div>`
88
93
 
94
+ // Plain text input — for values like "none", "auto", "100%", "0 4px 8px rgba(...)"
95
+ const textField = (label, prop, value) => {
96
+ const orig = originals[prop] || ''
97
+ return `
98
+ <div class="di-field">
99
+ <label>${label}</label>
100
+ <div class="di-field-control">
101
+ <input class="di-input" data-prop="${prop}" data-original="${escAttr(orig)}"
102
+ value="${escAttr(value || '')}" placeholder="auto" />
103
+ </div>
104
+ ${resetBtn(prop)}
105
+ </div>`
106
+ }
107
+
89
108
  // Spacing grid cell with mini stepper
90
109
  const spacingCell = (prop, val, label) => {
91
110
  const orig = originals[prop] || ''
@@ -135,8 +154,14 @@ export function renderEditPanel(panelContent, el, originals, onChange, onReset)
135
154
  </div>
136
155
  </div>` : ''
137
156
 
157
+ // ── Element type flags ────────────────────────────────────────────────────
158
+ const isImg = el.tagName === 'IMG'
159
+ const isSvg = el instanceof SVGElement
160
+ const isTextEl = ['p','h1','h2','h3','h4','h5','h6','span','a','li','td','th',
161
+ 'label','button','em','strong','small','code','pre','blockquote',
162
+ ].includes(el.tagName.toLowerCase())
163
+
138
164
  // ── Image section (Fix 1) ─────────────────────────────────────────────────
139
- const isImg = el.tagName === 'IMG'
140
165
  const imgSection = isImg ? `
141
166
  ${section('Image')}
142
167
  <div style="padding:8px 16px 12px;display:flex;flex-direction:column;gap:8px;">
@@ -153,6 +178,10 @@ export function renderEditPanel(panelContent, el, originals, onChange, onReset)
153
178
  ${section('Size')}
154
179
  ${numberField('width', 'width', dimensions.width, 'px')}
155
180
  ${numberField('height', 'height', dimensions.height, 'px')}
181
+ ${textField('min-width', 'min-width', cs.minWidth)}
182
+ ${textField('max-width', 'max-width', cs.maxWidth)}
183
+ ${textField('min-height', 'min-height', cs.minHeight)}
184
+ ${textField('max-height', 'max-height', cs.maxHeight)}
156
185
  `
157
186
 
158
187
  // ── Typography ────────────────────────────────────────────────────────────
@@ -160,12 +189,19 @@ export function renderEditPanel(panelContent, el, originals, onChange, onReset)
160
189
  const typoSection = `
161
190
  ${section('Typography')}
162
191
  ${numberField('font-size', 'font-size', typography.fontSize, 'px')}
192
+ ${selectField('font-style', 'font-style', cs.fontStyle,
193
+ ['normal','italic','oblique'])}
163
194
  ${selectField('font-weight', 'font-weight', typography.fontWeight,
164
195
  ['100','200','300','400','500','600','700','800','900'])}
165
196
  ${numberField('line-height', 'line-height', typography.lineHeight, 'px')}
166
197
  ${colorField('color', 'color', typography.colorHex, origColorHex)}
167
198
  ${selectField('text-align', 'text-align', typography.textAlign,
168
199
  ['left','center','right','justify'])}
200
+ ${selectField('decoration', 'text-decoration', cs.textDecorationLine,
201
+ ['none','underline','line-through','overline'])}
202
+ ${selectField('transform', 'text-transform', cs.textTransform,
203
+ ['none','uppercase','lowercase','capitalize'])}
204
+ ${textField('letter-spacing', 'letter-spacing', cs.letterSpacing)}
169
205
  `
170
206
 
171
207
  // ── Background ────────────────────────────────────────────────────────────
@@ -210,7 +246,7 @@ export function renderEditPanel(panelContent, el, originals, onChange, onReset)
210
246
  const layoutSection = `
211
247
  ${section('Layout')}
212
248
  ${selectField('display', 'display', layout.display,
213
- ['block','flex','grid','inline-flex','inline-block','none'])}
249
+ ['block','flex','grid','inline-flex','inline-block','inline','none'])}
214
250
  ${isFlex ? `
215
251
  ${selectField('direction', 'flex-direction', layout.flexDirection,
216
252
  ['row','row-reverse','column','column-reverse'])}
@@ -220,30 +256,72 @@ export function renderEditPanel(panelContent, el, originals, onChange, onReset)
220
256
  ['flex-start','flex-end','center','stretch','baseline'])}
221
257
  ` : ''}
222
258
  ${(isFlex || isGrid) ? numberField('gap', 'gap', layout.gap, 'px') : ''}
259
+ ${selectField('overflow', 'overflow', cs.overflow,
260
+ ['visible','hidden','auto','scroll','clip'])}
261
+ `
262
+
263
+ // ── Position ──────────────────────────────────────────────────────────────
264
+ const isPositioned = cs.position !== 'static'
265
+ const posSection = `
266
+ ${section('Position')}
267
+ ${selectField('position', 'position', cs.position,
268
+ ['static','relative','absolute','fixed','sticky'])}
269
+ ${isPositioned ? `
270
+ ${textField('top', 'top', cs.top)}
271
+ ${textField('right', 'right', cs.right)}
272
+ ${textField('bottom', 'bottom', cs.bottom)}
273
+ ${textField('left', 'left', cs.left)}
274
+ ` : ''}
275
+ ${textField('z-index', 'z-index', cs.zIndex === 'auto' ? 'auto' : cs.zIndex)}
223
276
  `
224
277
 
225
278
  // ── Misc ──────────────────────────────────────────────────────────────────
226
279
  const miscSection = `
227
280
  ${section('Misc')}
228
281
  ${numberField('opacity', 'opacity', opacity)}
282
+ ${textField('box-shadow', 'box-shadow', cs.boxShadow === 'none' ? '' : cs.boxShadow)}
283
+ ${isImg ? selectField('object-fit', 'object-fit', cs.objectFit,
284
+ ['fill','contain','cover','none','scale-down']) : ''}
285
+ `
286
+
287
+ // SVGs: show color only instead of full typography
288
+ const origColorHexForSvg = colorToHex(originals['color'])
289
+ const svgColorSection = `
290
+ ${section('Color')}
291
+ ${colorField('color', 'color', typography.colorHex, origColorHexForSvg)}
229
292
  `
230
293
 
231
- panelContent.innerHTML = `
294
+ // Typography visibility: hidden for images, color-only for SVGs, full for everything else
295
+ const typoRendered = isImg ? '' : isSvg ? svgColorSection : typoSection
296
+
297
+ // Section order: text elements get typography first for quick access
298
+ panelContent.innerHTML = isTextEl ? `
299
+ ${textSection}
300
+ ${typoRendered}
301
+ ${bgSection}
302
+ ${sizeSection}
303
+ ${spacingSection}
304
+ ${borderSection}
305
+ ${layoutSection}
306
+ ${posSection}
307
+ ${miscSection}
308
+ ` : `
232
309
  ${imgSection}
233
310
  ${textSection}
234
311
  ${sizeSection}
235
- ${typoSection}
312
+ ${typoRendered}
236
313
  ${bgSection}
237
314
  ${spacingSection}
238
315
  ${borderSection}
239
316
  ${layoutSection}
317
+ ${posSection}
240
318
  ${miscSection}
241
319
  `
242
320
 
243
321
  // ── Wire: regular inputs ───────────────────────────────────────────────────
244
322
  panelContent.querySelectorAll('.di-input[data-prop]').forEach(input => {
245
- input.addEventListener('input', () => applyChange(input, el, onChange, originals))
246
- input.addEventListener('change', () => applyChange(input, el, onChange, originals))
323
+ input.addEventListener('input', () => applyChange(input, el, onChange, originals, _apply))
324
+ input.addEventListener('change', () => applyChange(input, el, onChange, originals, _apply))
247
325
  })
248
326
 
249
327
  // ── Wire: color pickers ───────────────────────────────────────────────────
@@ -255,7 +333,7 @@ export function renderEditPanel(panelContent, el, originals, onChange, onReset)
255
333
  picker.addEventListener('input', () => {
256
334
  if (textBox) textBox.value = picker.value
257
335
  if (swatch) swatch.style.background = picker.value
258
- applyColorChange(picker.dataset.prop, picker.value, el, onChange, originals)
336
+ applyColorChange(picker.dataset.prop, picker.value, el, onChange, originals, _apply)
259
337
  markResetBtn(picker.dataset.prop, picker.value, picker.dataset.original)
260
338
  })
261
339
 
@@ -265,7 +343,7 @@ export function renderEditPanel(panelContent, el, originals, onChange, onReset)
265
343
  if (/^#[0-9a-fA-F]{3,6}$/.test(val)) {
266
344
  picker.value = val
267
345
  swatch.style.background = val
268
- applyColorChange(picker.dataset.prop, val, el, onChange, originals)
346
+ applyColorChange(picker.dataset.prop, val, el, onChange, originals, _apply)
269
347
  markResetBtn(textBox.dataset.prop, val, textBox.dataset.original)
270
348
  }
271
349
  })
@@ -281,11 +359,11 @@ export function renderEditPanel(panelContent, el, originals, onChange, onReset)
281
359
  const delta = parseFloat(btn.dataset.step)
282
360
  const current = parseFloat(input.value) || 0
283
361
  input.value = round(current + delta)
284
- applyChange(input, el, onChange, originals)
362
+ applyChange(input, el, onChange, originals, _apply)
285
363
  })
286
364
  })
287
365
 
288
- // ── Wire: arrow keys on numeric inputs (Fix 1) ────────────────────────────
366
+ // ── Wire: arrow keys on numeric inputs ────────────────────────────────────
289
367
  panelContent.querySelectorAll('.di-number-input').forEach(input => {
290
368
  input.addEventListener('keydown', (e) => {
291
369
  if (e.key !== 'ArrowUp' && e.key !== 'ArrowDown') return
@@ -294,7 +372,7 @@ export function renderEditPanel(panelContent, el, originals, onChange, onReset)
294
372
  const delta = e.key === 'ArrowUp' ? step : -step
295
373
  const current = parseFloat(input.value) || 0
296
374
  input.value = round(current + delta)
297
- applyChange(input, el, onChange, originals)
375
+ applyChange(input, el, onChange, originals, _apply)
298
376
  })
299
377
  })
300
378
 
@@ -302,7 +380,7 @@ export function renderEditPanel(panelContent, el, originals, onChange, onReset)
302
380
  panelContent.querySelectorAll('.di-reset-btn[data-reset-prop]').forEach(btn => {
303
381
  btn.addEventListener('click', () => {
304
382
  const prop = btn.dataset.resetProp
305
- el.style.removeProperty(prop)
383
+ _clear(prop)
306
384
  const restored = window.getComputedStyle(el).getPropertyValue(prop).trim()
307
385
 
308
386
  const input = panelContent.querySelector(`.di-input[data-prop="${prop}"]`)
@@ -382,7 +460,7 @@ export function renderEditPanel(panelContent, el, originals, onChange, onReset)
382
460
 
383
461
  // ── Helpers ───────────────────────────────────────────────────────────────────
384
462
 
385
- function applyChange(input, el, onChange, originals) {
463
+ function applyChange(input, el, onChange, originals, applyStyleFn) {
386
464
  const prop = input.dataset.prop
387
465
  const unit = input.dataset.unit || ''
388
466
  const rawVal = input.value.trim()
@@ -400,14 +478,14 @@ function applyChange(input, el, onChange, originals) {
400
478
  }
401
479
 
402
480
  const oldValue = el.style.getPropertyValue(prop) || window.getComputedStyle(el).getPropertyValue(prop)
403
- el.style.setProperty(prop, value)
481
+ applyStyleFn(prop, value)
404
482
  onChange(el, prop, oldValue, value)
405
483
  markResetBtn(prop, value, originals[prop])
406
484
  }
407
485
 
408
- function applyColorChange(prop, value, el, onChange, originals) {
486
+ function applyColorChange(prop, value, el, onChange, originals, applyStyleFn) {
409
487
  const oldValue = el.style.getPropertyValue(prop) || window.getComputedStyle(el).getPropertyValue(prop)
410
- el.style.setProperty(prop, value)
488
+ applyStyleFn(prop, value)
411
489
  onChange(el, prop, colorToHex(oldValue) || oldValue, value)
412
490
  }
413
491
 
@@ -492,6 +570,10 @@ function escHtml(str) {
492
570
  return str.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
493
571
  }
494
572
 
573
+ function escAttr(str) {
574
+ return String(str || '').replace(/"/g, '&quot;').replace(/</g, '&lt;')
575
+ }
576
+
495
577
  function camelize(str) {
496
578
  return str.replace(/-([a-z])/g, (_, c) => c.toUpperCase())
497
579
  }
package/src/feedback.js CHANGED
@@ -61,22 +61,22 @@ export function createChangeTracker() {
61
61
  grouped[selector].push({ property, from, to })
62
62
  })
63
63
 
64
- const lines = ['UI Feedback Summary', '===================', '']
64
+ const lines = [
65
+ '## UI Feedback Summary',
66
+ '',
67
+ `> ${changes.length} change${changes.length === 1 ? '' : 's'} · ${new Date().toLocaleString()}`,
68
+ '',
69
+ ]
65
70
 
66
71
  Object.entries(grouped).forEach(([selector, props]) => {
67
- lines.push(`[${selector}]`)
72
+ lines.push(`### \`${selector}\``)
68
73
  props.forEach(({ property, from, to }) => {
69
- const fromDisplay = from || '(none)'
70
- const toDisplay = to || '(none)'
71
- lines.push(` ${property}: ${fromDisplay} → ${toDisplay}`)
74
+ lines.push(`- **${property}**: \`${from || '(none)'}\` → \`${to || '(none)'}\``)
72
75
  })
73
76
  lines.push('')
74
77
  })
75
78
 
76
- lines.push(`Total changes: ${changes.length}`)
77
- lines.push(`Captured: ${new Date().toLocaleString()}`)
78
-
79
- return lines.join('\n')
79
+ return lines.join('\n').trimEnd()
80
80
  }
81
81
 
82
82
  function remove(el, property) {
package/src/inspector.js CHANGED
@@ -41,6 +41,7 @@ export function getElementStyles(el) {
41
41
  return {
42
42
  label,
43
43
  tag,
44
+ isSvg: el instanceof SVGElement,
44
45
  textContent,
45
46
  size: {
46
47
  width: Math.round(rect.width) + 'px',
@@ -196,7 +197,7 @@ export function updateBoxModel(bm, el) {
196
197
  }
197
198
 
198
199
  export function renderTooltip(tooltip, styles, x, y) {
199
- const { label, size, typography, background, spacing, border, layout, textContent } = styles
200
+ const { label, size, typography, background, spacing, border, layout, textContent, isSvg } = styles
200
201
 
201
202
  const colorDot = (hex) => hex
202
203
  ? `<span class="di-swatch" style="background:${hex};"></span>`
@@ -205,6 +206,7 @@ export function renderTooltip(tooltip, styles, x, y) {
205
206
  const row = (key, val) =>
206
207
  `<div class="di-row"><span class="di-key">${key}</span><span class="di-val">${val}</span></div>`
207
208
 
209
+ const isImg = styles.tag === 'img'
208
210
  const isText = ['p','h1','h2','h3','h4','h5','h6','span','a','li','td','th','label','button'].includes(styles.tag)
209
211
  const isFlex = layout.display === 'flex'
210
212
  const isGrid = layout.display === 'grid'
@@ -217,7 +219,8 @@ export function renderTooltip(tooltip, styles, x, y) {
217
219
 
218
220
  html += row('size', `${size.width} × ${size.height}`)
219
221
 
220
- if (isText || typography.fontSize !== '0px') {
222
+ // Typography: skip for images and SVGs
223
+ if (!isImg && !isSvg && (isText || typography.fontSize !== '0px')) {
221
224
  html += `<div class="di-section">Typography</div>`
222
225
  html += row('font', typography.fontFamily)
223
226
  html += row('size', typography.fontSize)
package/src/ui.js CHANGED
@@ -128,6 +128,7 @@ export function injectStyles() {
128
128
  min-width: 40px;
129
129
  }
130
130
  #__dev_inspector_panel__.collapsed .di-element-bar,
131
+ #__dev_inspector_panel__.collapsed .di-tab-row,
131
132
  #__dev_inspector_panel__.collapsed #di-panel-content,
132
133
  #__dev_inspector_panel__.collapsed .di-panel-footer,
133
134
  #__dev_inspector_panel__.collapsed .di-panel-header h3 {
@@ -702,6 +703,32 @@ export function injectStyles() {
702
703
  line-height: 1.6;
703
704
  }
704
705
 
706
+ /* ── Mode tabs (Element / Class) ── */
707
+ .di-tab-row {
708
+ display: flex;
709
+ padding: 6px 10px;
710
+ gap: 4px;
711
+ border-bottom: 1px solid #27272a;
712
+ flex-shrink: 0;
713
+ background: #18181b;
714
+ }
715
+ .di-tab {
716
+ padding: 4px 10px;
717
+ border-radius: 6px;
718
+ border: 1px solid transparent;
719
+ background: transparent;
720
+ color: #71717a;
721
+ font-size: 11px;
722
+ font-weight: 500;
723
+ cursor: pointer;
724
+ font-family: 'SF Mono', 'Fira Code', monospace;
725
+ transition: color 0.12s, background 0.12s, border-color 0.12s;
726
+ white-space: nowrap;
727
+ }
728
+ .di-tab:hover:not(:disabled) { color: #e4e4e7; background: #27272a; }
729
+ .di-tab.active { background: #27272a; border-color: #6366f1; color: #818cf8; }
730
+ .di-tab:disabled { opacity: 0.35; cursor: not-allowed; }
731
+
705
732
  /* ── Inspect cursor mode ── */
706
733
  body.di-inspect-mode * { cursor: crosshair !important; }
707
734
  `
@@ -757,6 +784,10 @@ export function createPanel() {
757
784
  <span class="di-element-label" id="di-element-label"></span>
758
785
  <button class="di-reinspect-btn" id="di-reinspect-btn">↖ Pick</button>
759
786
  </div>
787
+ <div class="di-tab-row" id="di-tab-row" style="display:none;">
788
+ <button class="di-tab active" id="di-tab-element">Element</button>
789
+ <button class="di-tab" id="di-tab-class"><span id="di-tab-class-label">.class</span></button>
790
+ </div>
760
791
  <div id="di-panel-content">
761
792
  <div class="di-panel-empty">
762
793
  <svg width="40" height="40" viewBox="0 0 24 24" fill="none" stroke="#6366f1" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round">
@@ -813,13 +844,9 @@ export function updateOverlay(overlay, el) {
813
844
  .filter(c => !c.startsWith('di-') && !c.startsWith('__dev'))
814
845
  .slice(0, 2).map(c => `.${c}`).join('')
815
846
  badge.textContent = `${tag}${id}${cls}`
816
- if (rect.top < 26) {
817
- badge.style.top = 'calc(100% + 2px)'
818
- badge.style.borderRadius = '0 0 4px 4px'
819
- } else {
820
- badge.style.top = '-22px'
821
- badge.style.borderRadius = '4px 4px 0 0'
822
- }
847
+ // Selected badge always sits above the element (static position)
848
+ badge.style.top = '-22px'
849
+ badge.style.borderRadius = '4px 4px 0 0'
823
850
  }
824
851
  }
825
852
 
@@ -850,13 +877,9 @@ export function updateHoverOverlay(overlay, el) {
850
877
  .filter(c => !c.startsWith('di-') && !c.startsWith('__dev'))
851
878
  .slice(0, 2).map(c => `.${c}`).join('')
852
879
  badge.textContent = `${tag}${id}${cls}`
853
- if (rect.top < 26) {
854
- badge.style.top = 'calc(100% + 2px)'
855
- badge.style.borderRadius = '0 0 4px 4px'
856
- } else {
857
- badge.style.top = '-22px'
858
- badge.style.borderRadius = '4px 4px 0 0'
859
- }
880
+ // Hover badge always sits below the highlighted element
881
+ badge.style.top = 'calc(100% + 2px)'
882
+ badge.style.borderRadius = '0 0 4px 4px'
860
883
  }
861
884
  }
862
885
 
@@ -882,28 +905,47 @@ export function updateSpacing(container, selEl, hovEl) {
882
905
  const vOverlap = sR.bottom > hR.top && hR.bottom > sR.top
883
906
  const lines = []
884
907
 
885
- if (!hOverlap) {
908
+ if (!hOverlap && !vOverlap) {
909
+ // ── Diagonal: L-shaped measurement anchored at the nearest corner ─────────
910
+ const isBelowV = hR.top >= sR.bottom
911
+ const isRightH = hR.left >= sR.right
912
+
913
+ const vGap = Math.round(isBelowV ? hR.top - sR.bottom : sR.top - hR.bottom)
914
+ const hGap = Math.round(isRightH ? hR.left - sR.right : sR.left - hR.right)
915
+
916
+ // Corner of the L: sR's nearest horizontal edge × hR's nearest vertical edge
917
+ const cornerX = isRightH ? sR.right : sR.left
918
+ const cornerY = isBelowV ? hR.top : hR.bottom
919
+
920
+ // Vertical segment: from sR's bottom/top down/up to cornerY
921
+ const vY1 = isBelowV ? sR.bottom : cornerY
922
+ const vY2 = isBelowV ? cornerY : sR.top
923
+ if (vGap > 0) lines.push({ x: cornerX - 0.5, y: vY1, w: 1, h: vY2 - vY1, label: vGap + 'px' })
924
+
925
+ // Horizontal segment: from cornerX across to hR's nearest edge, at cornerY
926
+ const hX1 = isRightH ? cornerX : hR.right
927
+ const hX2 = isRightH ? hR.left : cornerX
928
+ if (hGap > 0) lines.push({ x: hX1, y: cornerY - 0.5, w: hX2 - hX1, h: 1, label: hGap + 'px' })
929
+
930
+ } else if (!hOverlap) {
931
+ // ── Side by side (vertical overlap): single horizontal gap line ───────────
886
932
  const isRight = hR.left > sR.right
887
933
  const x1 = isRight ? sR.right : hR.right
888
- const x2 = isRight ? hR.left : sR.left
934
+ const x2 = isRight ? hR.left : sR.left
889
935
  const gap = Math.round(x2 - x1)
890
936
  if (gap > 0) {
891
- const y = vOverlap
892
- ? (Math.max(sR.top, hR.top) + Math.min(sR.bottom, hR.bottom)) / 2
893
- : (sR.top + sR.bottom) / 2
937
+ const y = (Math.max(sR.top, hR.top) + Math.min(sR.bottom, hR.bottom)) / 2
894
938
  lines.push({ x: x1, y: y - 0.5, w: x2 - x1, h: 1, label: gap + 'px' })
895
939
  }
896
- }
897
940
 
898
- if (!vOverlap) {
941
+ } else if (!vOverlap) {
942
+ // ── Stacked (horizontal overlap): single vertical gap line ────────────────
899
943
  const isBelow = hR.top > sR.bottom
900
944
  const y1 = isBelow ? sR.bottom : hR.bottom
901
- const y2 = isBelow ? hR.top : sR.top
945
+ const y2 = isBelow ? hR.top : sR.top
902
946
  const gap = Math.round(y2 - y1)
903
947
  if (gap > 0) {
904
- const x = hOverlap
905
- ? (Math.max(sR.left, hR.left) + Math.min(sR.right, hR.right)) / 2
906
- : (sR.left + sR.right) / 2
948
+ const x = (Math.max(sR.left, hR.left) + Math.min(sR.right, hR.right)) / 2
907
949
  lines.push({ x: x - 0.5, y: y1, w: 1, h: y2 - y1, label: gap + 'px' })
908
950
  }
909
951
  }