aetherx-ui-inspector 1.1.0 → 1.2.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.
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.1",
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,53 @@ export function init() {
36
76
  document.body.style.marginRight = width + 'px'
37
77
  }
38
78
 
79
+ // Hides hover overlay and resets selected badge back to its default top position
80
+ function hideHoverOverlay() {
81
+ hoverOverlay.style.display = 'none'
82
+ const selBadge = overlay.querySelector('.di-overlay-badge')
83
+ if (selBadge) {
84
+ selBadge.style.top = '-22px'
85
+ selBadge.style.borderRadius = '4px 4px 0 0'
86
+ }
87
+ }
88
+
89
+ // Repositions both badges so they never collide when elements are above/below each other
90
+ function repositionBadges(hovEl) {
91
+ if (!selectedEl || !hovEl) return
92
+ const sR = selectedEl.getBoundingClientRect()
93
+ const hR = hovEl.getBoundingClientRect()
94
+ const hovAbove = (hR.top + hR.bottom) / 2 < (sR.top + sR.bottom) / 2
95
+
96
+ const hovBadge = hoverOverlay.querySelector('.di-overlay-badge')
97
+ if (hovBadge) {
98
+ hovBadge.style.top = hovAbove ? '-22px' : 'calc(100% + 2px)'
99
+ hovBadge.style.borderRadius = hovAbove ? '4px 4px 0 0' : '0 0 4px 4px'
100
+ }
101
+ const selBadge = overlay.querySelector('.di-overlay-badge')
102
+ if (selBadge) {
103
+ selBadge.style.top = hovAbove ? 'calc(100% + 2px)' : '-22px'
104
+ selBadge.style.borderRadius = hovAbove ? '0 0 4px 4px' : '4px 4px 0 0'
105
+ }
106
+ }
107
+
108
+ // Animates overlay position updates over `ms` to cover CSS transition duration
109
+ function animateOverlayUpdate(ms = 260) {
110
+ if (!selectedEl) return
111
+ const end = performance.now() + ms
112
+ const tick = () => {
113
+ if (selectedEl) { updateOverlay(overlay, selectedEl); updateBoxModel(bm, selectedEl) }
114
+ if (performance.now() < end) requestAnimationFrame(tick)
115
+ }
116
+ requestAnimationFrame(tick)
117
+ }
118
+
39
119
  // ── State helpers ──────────────────────────────────────────────────────────
40
120
 
41
121
  function openPanel() {
42
122
  panelOpen = true
43
123
  panel.classList.add('open')
44
124
  syncBodyMargin()
125
+ animateOverlayUpdate()
45
126
  }
46
127
 
47
128
  function closePanel() {
@@ -49,8 +130,9 @@ export function init() {
49
130
  panel.classList.remove('open')
50
131
  selectedEl = null
51
132
  selectedOriginals = null
133
+ activeTab = 'element'
52
134
  overlay.style.display = 'none'
53
- hoverOverlay.style.display = 'none'
135
+ hideHoverOverlay()
54
136
  hideSpacing(spacingContainer)
55
137
  setElementBar(null)
56
138
  stopInspect(true)
@@ -61,7 +143,7 @@ export function init() {
61
143
  inspecting = true
62
144
  cog.classList.add('active')
63
145
  document.body.classList.add('di-inspect-mode')
64
- hoverOverlay.style.display = 'none'
146
+ hideHoverOverlay()
65
147
  hideSpacing(spacingContainer)
66
148
  // Mark re-inspect button as active if panel is open
67
149
  const reinspectBtn = document.getElementById('di-reinspect-btn')
@@ -89,7 +171,8 @@ export function init() {
89
171
 
90
172
  function selectElement(el) {
91
173
  textEditCleanup()
92
- hoverOverlay.style.display = 'none'
174
+ activeTab = 'element'
175
+ hideHoverOverlay()
93
176
  hideSpacing(spacingContainer)
94
177
 
95
178
  // Stop inspect mode without hiding the selected-element overlay
@@ -123,6 +206,7 @@ export function init() {
123
206
  if (!el) {
124
207
  bar.style.display = 'none'
125
208
  label.textContent = ''
209
+ updateTabRow(null)
126
210
  return
127
211
  }
128
212
  const tag = el.tagName.toLowerCase()
@@ -134,6 +218,21 @@ export function init() {
134
218
  .join('')
135
219
  label.textContent = `${tag}${id}${cls}`
136
220
  bar.style.display = 'flex'
221
+ updateTabRow(el)
222
+ }
223
+
224
+ function updateTabRow(el) {
225
+ const tabRow = document.getElementById('di-tab-row')
226
+ const classTab = document.getElementById('di-tab-class')
227
+ const classLabel = document.getElementById('di-tab-class-label')
228
+ if (!tabRow) return
229
+ if (!el) { tabRow.style.display = 'none'; return }
230
+ tabRow.style.display = 'flex'
231
+ const cls = getSelectedClass(el)
232
+ if (classLabel) classLabel.textContent = cls ? `.${cls}` : 'No class'
233
+ if (classTab) classTab.disabled = !cls
234
+ document.getElementById('di-tab-element')?.classList.toggle('active', activeTab === 'element')
235
+ classTab?.classList.toggle('active', activeTab === 'class')
137
236
  }
138
237
 
139
238
  function renderPanel(el) {
@@ -156,6 +255,15 @@ export function init() {
156
255
  if (footer) footer.style.display = 'flex'
157
256
  updateFeedbackCount()
158
257
 
258
+ const cls = getSelectedClass(el)
259
+ const isClassMode = activeTab === 'class' && !!cls
260
+ const applyStyle = isClassMode
261
+ ? (prop, val) => setClassRule(cls, prop, val)
262
+ : (prop, val) => el.style.setProperty(prop, val)
263
+ const clearStyle = isClassMode
264
+ ? (prop) => removeClassRule(cls, prop)
265
+ : (prop) => el.style.removeProperty(prop)
266
+
159
267
  textEditCleanup = renderEditPanel(
160
268
  content,
161
269
  el,
@@ -171,7 +279,8 @@ export function init() {
171
279
  tracker.remove(el, prop)
172
280
  updateFeedbackCount()
173
281
  if (selectedEl) { updateOverlay(overlay, selectedEl); updateBoxModel(bm, selectedEl) }
174
- }
282
+ },
283
+ { applyStyle, clearStyle }
175
284
  )
176
285
  }
177
286
 
@@ -251,11 +360,27 @@ export function init() {
251
360
  }
252
361
  })
253
362
 
363
+ // ── Mode tabs ──────────────────────────────────────────────────────────────
364
+ document.getElementById('di-tab-element')?.addEventListener('click', () => {
365
+ if (activeTab === 'element') return
366
+ activeTab = 'element'
367
+ updateTabRow(selectedEl)
368
+ if (selectedEl) renderPanel(selectedEl)
369
+ })
370
+
371
+ document.getElementById('di-tab-class')?.addEventListener('click', () => {
372
+ if (activeTab === 'class' || !getSelectedClass(selectedEl)) return
373
+ activeTab = 'class'
374
+ updateTabRow(selectedEl)
375
+ if (selectedEl) renderPanel(selectedEl)
376
+ })
377
+
254
378
  // ── Collapse toggle ────────────────────────────────────────────────────────
255
379
  document.getElementById('di-collapse-btn')?.addEventListener('click', () => {
256
380
  isCollapsed = !isCollapsed
257
381
  panel.classList.toggle('collapsed', isCollapsed)
258
382
  syncBodyMargin()
383
+ animateOverlayUpdate()
259
384
  })
260
385
 
261
386
  // ── Resize handle (drag left edge to resize panel width) ───────────────────
@@ -281,6 +406,7 @@ export function init() {
281
406
  const newWidth = Math.min(580, Math.max(220, resizeStartWidth + dx))
282
407
  panel.style.width = newWidth + 'px'
283
408
  syncBodyMargin()
409
+ if (selectedEl) { updateOverlay(overlay, selectedEl); updateBoxModel(bm, selectedEl) }
284
410
  })
285
411
 
286
412
  document.addEventListener('mouseup', () => {
@@ -357,23 +483,37 @@ export function init() {
357
483
  renderTooltip(tooltip, styles, e.clientX, e.clientY)
358
484
  })
359
485
 
360
- // ── Hover while selected: spacing measurements ────────────────────────────
486
+ // ── Hover while selected: spacing + tooltip on self ──────────────────────
361
487
  document.addEventListener('mousemove', (e) => {
362
488
  if (inspecting || !selectedEl) {
363
489
  if (hoverOverlay.style.display !== 'none') {
364
- hoverOverlay.style.display = 'none'
490
+ hideHoverOverlay()
365
491
  hideSpacing(spacingContainer)
366
492
  }
493
+ // Don't touch the tooltip here — inspect-mode handler owns it when inspecting
494
+ if (!inspecting) tooltip.style.display = 'none'
367
495
  return
368
496
  }
369
497
  const el = e.target
370
- if (isInspectorElement(el) || el === selectedEl) {
371
- hoverOverlay.style.display = 'none'
498
+ if (isInspectorElement(el)) {
499
+ hideHoverOverlay()
500
+ hideSpacing(spacingContainer)
501
+ tooltip.style.display = 'none'
502
+ return
503
+ }
504
+ if (el === selectedEl) {
505
+ // Hovering the selected element itself — show its tooltip, no spacing lines
506
+ hideHoverOverlay()
372
507
  hideSpacing(spacingContainer)
508
+ const styles = getElementStyles(el)
509
+ renderTooltip(tooltip, styles, e.clientX, e.clientY)
373
510
  return
374
511
  }
512
+ // Hovering a different element — show spacing measurements, reposition badges
375
513
  updateHoverOverlay(hoverOverlay, el)
376
514
  updateSpacing(spacingContainer, selectedEl, el)
515
+ repositionBadges(el)
516
+ tooltip.style.display = 'none'
377
517
  })
378
518
 
379
519
  // ── Click: select element (works whenever panel is open OR inspect is active)
@@ -386,12 +526,25 @@ export function init() {
386
526
  selectElement(el)
387
527
  }, true)
388
528
 
389
- // ── Escape key ─────────────────────────────────────────────────────────────
529
+ // ── Keyboard shortcuts ─────────────────────────────────────────────────────
390
530
  document.addEventListener('keydown', (e) => {
531
+ // Skip if user is typing inside an input / textarea / contenteditable
532
+ const tag = document.activeElement?.tagName.toLowerCase()
533
+ const isTyping = ['input', 'textarea', 'select'].includes(tag)
534
+ || document.activeElement?.isContentEditable
535
+
391
536
  if (e.key === 'Escape') {
392
537
  if (inspecting) stopInspect()
393
538
  else if (panelOpen) closePanel()
394
539
  }
540
+
541
+ // i — toggle inspect mode while panel is open
542
+ if ((e.key === 'i' || e.key === 'I') && !isTyping && !e.metaKey && !e.ctrlKey && !e.altKey) {
543
+ if (panelOpen) {
544
+ if (inspecting) stopInspect()
545
+ else startInspect()
546
+ }
547
+ }
395
548
  })
396
549
 
397
550
  // ── Fix 1: scroll — update overlay using fresh getBoundingClientRect ────────
@@ -406,9 +559,10 @@ export function init() {
406
559
  tooltip.style.display = 'none'
407
560
  hideBoxModel(bm)
408
561
  }
409
- // Clear hover overlay and spacing on scroll (positions are stale)
410
- hoverOverlay.style.display = 'none'
562
+ // Clear hover overlay, spacing, and tooltip on scroll (positions are stale)
563
+ hideHoverOverlay()
411
564
  hideSpacing(spacingContainer)
565
+ tooltip.style.display = 'none'
412
566
  }, true)
413
567
 
414
568
  return {
@@ -421,6 +575,9 @@ export function init() {
421
575
  bm.remove()
422
576
  hoverOverlay.remove()
423
577
  spacingContainer.remove()
578
+ classStyleEl?.remove()
579
+ classStyleEl = null
580
+ classRules = {}
424
581
  document.getElementById('__dev_inspector_styles__')?.remove()
425
582
  document.body.classList.remove('di-inspect-mode')
426
583
  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
  }