@maizzle/framework 6.0.0-rc.6 → 6.0.0-rc.8

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 (86) hide show
  1. package/dist/components/Body.vue +105 -36
  2. package/dist/components/Button.vue +4 -1
  3. package/dist/components/CodeBlock.vue +11 -18
  4. package/dist/components/CodeInline.vue +6 -1
  5. package/dist/components/Column.vue +30 -5
  6. package/dist/components/Container.vue +10 -2
  7. package/dist/components/Divider.vue +28 -0
  8. package/dist/components/Head.vue +22 -0
  9. package/dist/components/Heading.vue +28 -0
  10. package/dist/components/Html.vue +98 -47
  11. package/dist/components/Layout.vue +93 -0
  12. package/dist/components/Link.vue +26 -0
  13. package/dist/components/Markdown.vue +83 -0
  14. package/dist/components/Outlook.vue +36 -0
  15. package/dist/components/Overlap.vue +25 -5
  16. package/dist/components/{Preview.vue → Preheader.vue} +1 -1
  17. package/dist/components/Row.vue +16 -5
  18. package/dist/components/Section.vue +83 -0
  19. package/dist/components/Text.vue +29 -0
  20. package/dist/components/Vml.vue +165 -13
  21. package/dist/plugins/postcss/tailwindCleanup.mjs +22 -13
  22. package/dist/plugins/postcss/tailwindCleanup.mjs.map +1 -1
  23. package/dist/render/createRenderer.d.mts +2 -3
  24. package/dist/render/createRenderer.d.mts.map +1 -1
  25. package/dist/render/createRenderer.mjs +67 -4
  26. package/dist/render/createRenderer.mjs.map +1 -1
  27. package/dist/serve.d.mts.map +1 -1
  28. package/dist/serve.mjs +84 -4
  29. package/dist/serve.mjs.map +1 -1
  30. package/dist/server/compatibility.d.mts +1 -2
  31. package/dist/server/compatibility.d.mts.map +1 -1
  32. package/dist/server/compatibility.mjs +30 -16
  33. package/dist/server/compatibility.mjs.map +1 -1
  34. package/dist/server/email.d.mts +17 -0
  35. package/dist/server/email.d.mts.map +1 -0
  36. package/dist/server/email.mjs +41 -0
  37. package/dist/server/email.mjs.map +1 -0
  38. package/dist/server/linter.d.mts +1 -2
  39. package/dist/server/linter.d.mts.map +1 -1
  40. package/dist/server/linter.mjs +60 -71
  41. package/dist/server/linter.mjs.map +1 -1
  42. package/dist/server/ui/App.vue +205 -69
  43. package/dist/server/ui/components/ui/checkbox/Checkbox.vue +35 -0
  44. package/dist/server/ui/components/ui/checkbox/index.ts +1 -0
  45. package/dist/server/ui/components/ui/command/CommandDialog.vue +1 -1
  46. package/dist/server/ui/components/ui/command/CommandInput.vue +19 -1
  47. package/dist/server/ui/components/ui/command/CommandItem.vue +1 -1
  48. package/dist/server/ui/components/ui/command/CommandList.vue +1 -1
  49. package/dist/server/ui/components/ui/command/CommandShortcut.vue +1 -1
  50. package/dist/server/ui/components/ui/dialog/DialogOverlay.vue +9 -1
  51. package/dist/server/ui/components/ui/dropdown-menu/DropdownMenuItem.vue +1 -1
  52. package/dist/server/ui/components/ui/scroll-area/ScrollBar.vue +1 -1
  53. package/dist/server/ui/components/ui/sheet/SheetContent.vue +1 -1
  54. package/dist/server/ui/components/ui/sheet/SheetOverlay.vue +9 -1
  55. package/dist/server/ui/components/ui/sidebar/Sidebar.vue +8 -1
  56. package/dist/server/ui/components/ui/sidebar/SidebarProvider.vue +1 -1
  57. package/dist/server/ui/components/ui/sidebar/SidebarTrigger.vue +5 -4
  58. package/dist/server/ui/components/ui/tags-input/TagsInput.vue +26 -0
  59. package/dist/server/ui/components/ui/tags-input/TagsInputInput.vue +17 -0
  60. package/dist/server/ui/components/ui/tags-input/TagsInputItem.vue +19 -0
  61. package/dist/server/ui/components/ui/tags-input/TagsInputItemDelete.vue +22 -0
  62. package/dist/server/ui/components/ui/tags-input/TagsInputItemText.vue +17 -0
  63. package/dist/server/ui/components/ui/tags-input/index.ts +5 -0
  64. package/dist/server/ui/components/ui/toggle/index.ts +3 -3
  65. package/dist/server/ui/components/ui/toggle-group/ToggleGroup.vue +1 -1
  66. package/dist/server/ui/components/ui/toggle-group/ToggleGroupItem.vue +2 -2
  67. package/dist/server/ui/main.css +20 -20
  68. package/dist/server/ui/pages/Home.vue +12 -5
  69. package/dist/server/ui/pages/Preview.vue +495 -211
  70. package/dist/transformers/inlineCSS.d.mts +1 -14
  71. package/dist/transformers/inlineCSS.d.mts.map +1 -1
  72. package/dist/transformers/inlineCSS.mjs +25 -34
  73. package/dist/transformers/inlineCSS.mjs.map +1 -1
  74. package/dist/transformers/purgeCSS.d.mts.map +1 -1
  75. package/dist/transformers/purgeCSS.mjs +67 -1
  76. package/dist/transformers/purgeCSS.mjs.map +1 -1
  77. package/dist/transformers/tailwindcss.mjs +3 -7
  78. package/dist/transformers/tailwindcss.mjs.map +1 -1
  79. package/dist/types/config.d.mts +47 -29
  80. package/dist/types/config.d.mts.map +1 -1
  81. package/dist/types/index.d.mts +2 -2
  82. package/package.json +7 -3
  83. package/dist/server/ui/components/ui/resizable/ResizableHandle.vue +0 -30
  84. package/dist/server/ui/components/ui/resizable/ResizablePanel.vue +0 -21
  85. package/dist/server/ui/components/ui/resizable/ResizablePanelGroup.vue +0 -25
  86. package/dist/server/ui/components/ui/resizable/index.ts +0 -3
@@ -1,20 +1,26 @@
1
1
  <script setup lang="ts">
2
- import { ref, watch, nextTick, onMounted, onUnmounted } from 'vue'
2
+ import { ref, computed, watch, nextTick, onMounted, onUnmounted } from 'vue'
3
3
  import { useRoute } from 'vue-router'
4
- import { ChevronUp, ChevronDown, Check } from 'lucide-vue-next'
4
+ import { ChevronUp, ChevronDown, Check, Info } from 'lucide-vue-next'
5
5
  import {
6
6
  DropdownMenu,
7
7
  DropdownMenuContent,
8
8
  DropdownMenuItem,
9
9
  DropdownMenuTrigger,
10
10
  } from '@/components/ui/dropdown-menu'
11
- import {
12
- ResizableHandle,
13
- ResizablePanel,
14
- ResizablePanelGroup,
15
- } from '@/components/ui/resizable'
16
11
  import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
17
12
  import { Button } from '@/components/ui/button'
13
+ import { Input } from '@/components/ui/input'
14
+ import { ScrollArea, ScrollBar } from '@/components/ui/scroll-area'
15
+ import { Checkbox } from '@/components/ui/checkbox'
16
+ import {
17
+ TagsInput,
18
+ TagsInputInput,
19
+ TagsInputItem,
20
+ TagsInputItemDelete,
21
+ TagsInputItemText,
22
+ } from '@/components/ui/tags-input'
23
+ import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip'
18
24
 
19
25
  import stripesUrl from '../stripes.svg'
20
26
 
@@ -24,9 +30,16 @@ interface Device {
24
30
  height: number
25
31
  }
26
32
 
33
+ interface Template {
34
+ name: string
35
+ path: string
36
+ href: string
37
+ }
38
+
27
39
  const props = defineProps<{
28
40
  device?: Device | null
29
41
  resetKey?: number
42
+ templates?: Template[]
30
43
  }>()
31
44
 
32
45
  const viewMode = defineModel<'preview' | 'source'>('viewMode', { default: 'preview' })
@@ -40,45 +53,45 @@ const sourceView = ref<'compiled' | 'vue' | 'plaintext'>('compiled')
40
53
  const copied = ref(false)
41
54
 
42
55
  const iframeEl = ref<HTMLIFrameElement>()
56
+ const compiledSourceEl = ref<HTMLElement>()
43
57
  const vueSourceEl = ref<HTMLElement>()
44
58
  const containerEl = ref<HTMLElement>()
45
- const previewEl = ref<InstanceType<typeof ResizablePanel>>()
46
- const leftPanel = ref<InstanceType<typeof ResizablePanel>>()
47
- const rightPanel = ref<InstanceType<typeof ResizablePanel>>()
48
- const topPanel = ref<InstanceType<typeof ResizablePanel>>()
49
- const bottomPanel = ref<InstanceType<typeof ResizablePanel>>()
59
+ const wrapperEl = ref<HTMLElement>()
50
60
 
51
61
  const panelWidth = defineModel<number>('panelWidth', { default: 0 })
52
62
  const panelHeight = defineModel<number>('panelHeight', { default: 0 })
53
63
  const isDragging = defineModel<boolean>('isDragging', { default: false })
54
64
  const isFullSize = defineModel<boolean>('isFullSize', { default: true })
55
65
 
56
- const sideSizes = ref({ left: 0, right: 0, top: 0, bottom: 0 })
66
+ // Custom resizable: width/height of the iframe wrapper (null = fill container)
67
+ const iframeWidth = ref<number | null>(null)
68
+ const iframeHeight = ref<number | null>(null)
69
+ const iframeContentHeight = ref<number | null>(null)
57
70
 
58
- function updateFullSize() {
59
- isFullSize.value = sideSizes.value.left < 0.5
60
- && sideSizes.value.right < 0.5
61
- && sideSizes.value.top < 0.5
62
- && sideSizes.value.bottom < 0.5
63
- }
64
-
65
- async function copySource() {
71
+ function copySource() {
72
+ let text: string
66
73
  if (sourceView.value === 'compiled') {
67
- await navigator.clipboard.writeText(srcdoc.value)
74
+ text = srcdoc.value
68
75
  } else if (sourceView.value === 'plaintext') {
69
- await navigator.clipboard.writeText(plaintextContent.value)
76
+ text = plaintextContent.value
70
77
  } else {
71
78
  const el = document.createElement('div')
72
79
  el.innerHTML = vueSourceHtml.value
73
- await navigator.clipboard.writeText(el.textContent || '')
80
+ text = el.textContent || ''
74
81
  }
75
- copied.value = true
76
- setTimeout(() => { copied.value = false }, 2000)
82
+
83
+ const blob = new Blob([text], { type: 'text/plain' })
84
+ const item = new ClipboardItem({ 'text/plain': blob })
85
+ navigator.clipboard.write([item]).then(() => {
86
+ copied.value = true
87
+ setTimeout(() => { copied.value = false }, 2000)
88
+ })
77
89
  }
78
90
 
79
91
  interface CompatibilityIssue {
80
92
  type: 'error' | 'warning'
81
93
  title: string
94
+ category: string
82
95
  clients: Array<{ name: string, notes: string[] }>
83
96
  url?: string
84
97
  line?: number
@@ -99,14 +112,111 @@ interface TemplateStats {
99
112
 
100
113
  const compatibilityIssues = ref<CompatibilityIssue[]>([])
101
114
  const compatibilityLoading = ref(false)
115
+ const compatibilityError = ref('')
116
+ const compatibilityCategory = ref('')
117
+ const compatibilityCategories = ['css', 'html', 'image', 'others'] as const
118
+ const activeCompatibilityCategories = computed(() =>
119
+ compatibilityCategories.filter(cat => compatibilityIssues.value.some(i => i.category === cat))
120
+ )
121
+ const filteredCompatibilityIssues = computed(() => {
122
+ if (!compatibilityCategory.value) return compatibilityIssues.value
123
+ return compatibilityIssues.value.filter(i => i.category === compatibilityCategory.value)
124
+ })
102
125
  const lintIssues = ref<LintIssue[]>([])
103
126
  const lintLoading = ref(false)
104
127
  const stats = ref<TemplateStats | null>(null)
105
128
  const statsLoading = ref(false)
106
129
 
130
+ // Email test state
131
+ const emailTo = ref<string[]>([])
132
+ const emailSubject = ref('')
133
+ const emailSending = ref(false)
134
+ const emailPreventThreading = ref(true)
135
+ const emailResult = ref<{ success: boolean; message: string; previewUrl?: string } | null>(null)
136
+
137
+ async function fetchEmailConfig() {
138
+ try {
139
+ const res = await fetch('/__maizzle/email-config')
140
+ const data = await res.json()
141
+ if (data.to?.length && !emailTo.value.length) emailTo.value = data.to
142
+ if (data.subject && !emailSubject.value) emailSubject.value = data.subject
143
+ } catch {}
144
+ }
145
+
146
+ async function sendTestEmail() {
147
+ if (!emailTo.value.length) return
148
+ emailSending.value = true
149
+ emailResult.value = null
150
+
151
+ try {
152
+ const res = await fetch(`/__maizzle/email/${route.params.template}`, {
153
+ method: 'POST',
154
+ headers: { 'Content-Type': 'application/json' },
155
+ body: JSON.stringify({
156
+ to: emailTo.value,
157
+ subject: (() => {
158
+ let subj = emailSubject.value || String(route.params.template)
159
+ if (emailPreventThreading.value) {
160
+ subj += ` | ${new Date().toISOString().slice(0, 19)}`
161
+ }
162
+ return subj
163
+ })(),
164
+ }),
165
+ })
166
+ emailResult.value = await res.json()
167
+ } catch (error: any) {
168
+ emailResult.value = { success: false, message: error.message }
169
+ } finally {
170
+ emailSending.value = false
171
+ }
172
+ }
173
+
174
+ let renderedHtml = ''
175
+
176
+ function updateIframeContentHeight() {
177
+ const iframe = iframeEl.value
178
+ const doc = iframe?.contentDocument
179
+ if (!iframe || !doc?.documentElement) return
180
+
181
+ // Hide iframe body overflow — scrolling is handled by the outer ScrollArea
182
+ if (doc.body) doc.body.style.overflow = 'hidden'
183
+
184
+ // Save scroll position of the ScrollArea viewport
185
+ const viewport = wrapperEl.value?.querySelector('[data-slot="scroll-area-viewport"]')
186
+ const scrollTop = viewport?.scrollTop ?? 0
187
+
188
+ // Temporarily collapse to measure true content height
189
+ iframe.style.height = '0'
190
+ iframeContentHeight.value = doc.documentElement.scrollHeight
191
+ iframe.style.height = `${iframeContentHeight.value}px`
192
+
193
+ // Restore scroll position
194
+ if (viewport) {
195
+ viewport.scrollTop = scrollTop
196
+ }
197
+ }
198
+
107
199
  async function fetchTemplate() {
108
200
  const res = await fetch(`/__maizzle/render/${route.params.template}`)
109
- srcdoc.value = await res.text()
201
+ renderedHtml = await res.text()
202
+
203
+ const iframe = iframeEl.value
204
+ const doc = iframe?.contentDocument
205
+
206
+ // Write directly into the iframe document to avoid a full reload,
207
+ // which preserves scroll position natively.
208
+ if (doc) {
209
+ doc.open()
210
+ doc.write(renderedHtml)
211
+ doc.close()
212
+ // Hide iframe body overflow — scrolling is handled by the outer ScrollArea
213
+ if (doc.body) doc.body.style.overflow = 'hidden'
214
+ await nextTick()
215
+ updateIframeContentHeight()
216
+ } else {
217
+ // Fallback for initial load
218
+ srcdoc.value = renderedHtml
219
+ }
110
220
  }
111
221
 
112
222
  async function fetchSource() {
@@ -138,9 +248,22 @@ async function fetchStats() {
138
248
 
139
249
  async function fetchCompatibility() {
140
250
  compatibilityLoading.value = true
251
+ compatibilityError.value = ''
141
252
  try {
142
- const res = await fetch(`/__maizzle/compatibility/${route.params.template}`)
143
- compatibilityIssues.value = await res.json()
253
+ const res = await fetch('/__maizzle/compatibility', {
254
+ method: 'POST',
255
+ body: renderedHtml,
256
+ })
257
+ const data = await res.json()
258
+ if (data?.error) {
259
+ compatibilityError.value = data.error
260
+ compatibilityIssues.value = []
261
+ } else {
262
+ compatibilityIssues.value = data
263
+ // Default to first category that has issues
264
+ const firstCat = compatibilityCategories.find(cat => data.some((i: CompatibilityIssue) => i.category === cat))
265
+ compatibilityCategory.value = firstCat || ''
266
+ }
144
267
  } catch {
145
268
  compatibilityIssues.value = []
146
269
  } finally {
@@ -151,8 +274,11 @@ async function fetchCompatibility() {
151
274
  async function fetchLint() {
152
275
  lintLoading.value = true
153
276
  try {
154
- const res = await fetch(`/__maizzle/lint/${route.params.template}`)
155
- lintIssues.value = await res.json()
277
+ const template = props.templates?.find(t => t.href === '/' + route.params.template)
278
+ const filePath = template?.path ?? route.params.template
279
+ const res = await fetch(`/__maizzle/lint/${filePath}`)
280
+ const data = await res.json()
281
+ lintIssues.value = Array.isArray(data) ? data.filter((i: LintIssue) => i.title) : []
156
282
  } catch {
157
283
  lintIssues.value = []
158
284
  } finally {
@@ -165,13 +291,15 @@ watch(() => route.params.template, () => {
165
291
  vueSourceHtml.value = ''
166
292
  plaintextContent.value = ''
167
293
  compatibilityIssues.value = []
294
+ compatibilityError.value = ''
168
295
  lintIssues.value = []
169
296
  stats.value = null
297
+ emailResult.value = null
170
298
  sourceView.value = 'compiled'
171
- fetchTemplate()
172
- fetchCompatibility()
299
+ fetchTemplate().then(fetchCompatibility)
173
300
  fetchLint()
174
301
  fetchStats()
302
+ fetchEmailConfig()
175
303
  if (viewMode.value === 'source') fetchSource()
176
304
  }, { immediate: true })
177
305
 
@@ -191,8 +319,7 @@ watch(sourceView, (view) => {
191
319
 
192
320
  if ((import.meta as any).hot) {
193
321
  ;(import.meta as any).hot.on('maizzle:template-updated', () => {
194
- fetchTemplate()
195
- fetchCompatibility()
322
+ fetchTemplate().then(fetchCompatibility)
196
323
  fetchLint()
197
324
  fetchStats()
198
325
 
@@ -237,74 +364,112 @@ async function goToLine(line: number) {
237
364
  }
238
365
  }
239
366
 
240
- // Track which axis is being user-dragged so we can sync the opposite panel
241
- let hDragging = false
242
- let vDragging = false
367
+ async function goToCompiledLine(line: number) {
368
+ viewMode.value = 'source'
369
+ sourceView.value = 'compiled'
243
370
 
244
- const emit = defineEmits<{ 'clear-device': [] }>()
371
+ if (!sourceHtml.value) {
372
+ await fetchSource()
373
+ }
245
374
 
246
- function onHDragStart() { hDragging = true; isDragging.value = true; emit('clear-device') }
247
- function onHDragEnd() { setTimeout(() => { hDragging = false }, 50); isDragging.value = false }
248
- function onVDragStart() { vDragging = true; isDragging.value = true; emit('clear-device') }
249
- function onVDragEnd() { setTimeout(() => { vDragging = false }, 50); isDragging.value = false }
375
+ await nextTick()
250
376
 
251
- function onHorizontalLayout(sizes: number[]) {
252
- if (!hDragging) return
377
+ const el = compiledSourceEl.value
378
+ if (!el) return
253
379
 
254
- const [left, , right] = sizes
255
- if (Math.abs(left - right) < 0.5) return
380
+ el.querySelectorAll('.shiki-highlight-line').forEach(l => l.classList.remove('shiki-highlight-line'))
256
381
 
257
- hDragging = false
258
- const side = Math.max(left, right)
259
- if (left < side) leftPanel.value?.resize(side)
260
- if (right < side) rightPanel.value?.resize(side)
382
+ const lineEl = el.querySelector(`[data-line="${line}"]`)
383
+ if (lineEl) {
384
+ lineEl.classList.add('shiki-highlight-line')
385
+ lineEl.scrollIntoView({ block: 'center', behavior: 'smooth' })
386
+ }
261
387
  }
262
388
 
263
- function onVerticalLayout(sizes: number[]) {
264
- if (!vDragging) return
389
+ const emit = defineEmits<{ 'clear-device': [] }>()
390
+
391
+ type Edge = 'left' | 'right' | 'top' | 'bottom'
392
+
393
+ function onEdgeDrag(e: MouseEvent | TouchEvent, edge: Edge) {
394
+ e.preventDefault()
395
+ isDragging.value = true
396
+ emit('clear-device')
397
+
398
+ const container = containerEl.value
399
+ if (!container) return
400
+
401
+ const isTouch = e.type === 'touchstart'
402
+ const startPoint = isTouch ? (e as TouchEvent).touches[0] : (e as MouseEvent)
403
+ const startX = startPoint.clientX
404
+ const startY = startPoint.clientY
405
+ const rect = container.getBoundingClientRect()
406
+ const gutter = 40 // 20px padding on each side
407
+ const maxW = rect.width - gutter
408
+ const maxH = rect.height - gutter
409
+ const startW = iframeWidth.value ?? maxW
410
+ const startH = iframeHeight.value ?? maxH
411
+
412
+ const isHorizontal = edge === 'left' || edge === 'right'
413
+ const sign = (edge === 'left' || edge === 'top') ? -1 : 1
414
+
415
+ const onMove = (ev: MouseEvent | TouchEvent) => {
416
+ const point = ev.type === 'touchmove' ? (ev as TouchEvent).touches[0] : (ev as MouseEvent)
417
+ if (isHorizontal) {
418
+ // Symmetric: each side moves by the delta, so total change is 2x
419
+ const delta = (point.clientX - startX) * sign
420
+ iframeWidth.value = Math.max(200, Math.min(maxW, startW + delta * 2))
421
+ } else {
422
+ const delta = (point.clientY - startY) * sign
423
+ iframeHeight.value = Math.max(100, Math.min(maxH, startH + delta * 2))
424
+ }
425
+ }
265
426
 
266
- const [top, , bottom] = sizes
267
- if (Math.abs(top - bottom) < 0.5) return
427
+ const onUp = () => {
428
+ isDragging.value = false
429
+ updateFullSize()
430
+ document.removeEventListener('mousemove', onMove)
431
+ document.removeEventListener('mouseup', onUp)
432
+ document.removeEventListener('touchmove', onMove)
433
+ document.removeEventListener('touchend', onUp)
434
+ }
268
435
 
269
- vDragging = false
270
- const side = Math.max(top, bottom)
271
- if (top < side) topPanel.value?.resize(side)
272
- if (bottom < side) bottomPanel.value?.resize(side)
436
+ document.addEventListener('mousemove', onMove)
437
+ document.addEventListener('mouseup', onUp)
438
+ document.addEventListener('touchmove', onMove, { passive: false })
439
+ document.addEventListener('touchend', onUp)
273
440
  }
274
441
 
275
- function applyDeviceSize(device: Device | null | undefined) {
276
- const el = containerEl.value
277
- if (!el) return
442
+ function updateFullSize() {
443
+ const container = containerEl.value
444
+ if (!container) return
445
+ const rect = container.getBoundingClientRect()
446
+ const gutter = 40
447
+ isFullSize.value = (iframeWidth.value === null || iframeWidth.value >= rect.width - gutter - 2)
448
+ && (iframeHeight.value === null || iframeHeight.value >= rect.height - gutter - 2)
449
+ }
278
450
 
451
+ function applyDeviceSize(device: Device | null | undefined) {
279
452
  if (!device) {
280
- if (!hDragging && !vDragging) {
281
- leftPanel.value?.resize(0)
282
- rightPanel.value?.resize(0)
283
- topPanel.value?.resize(0)
284
- bottomPanel.value?.resize(0)
285
- }
453
+ iframeWidth.value = null
454
+ iframeHeight.value = null
455
+ updateFullSize()
286
456
  return
287
457
  }
288
458
 
289
- const rect = el.getBoundingClientRect()
290
- if (!rect.width || !rect.height) return
459
+ const container = containerEl.value
460
+ if (!container) return
461
+ const rect = container.getBoundingClientRect()
462
+ const gutter = 40
291
463
 
292
- const handleSize = 16
293
- const hPanelSpace = rect.width - handleSize * 2
294
- const vPanelSpace = rect.height - handleSize * 2
295
-
296
- const hSide = Math.max(0, ((hPanelSpace - device.width) / 2) / hPanelSpace * 100)
297
- const vSide = Math.max(0, ((vPanelSpace - device.height) / 2) / vPanelSpace * 100)
298
-
299
- leftPanel.value?.resize(hSide)
300
- rightPanel.value?.resize(hSide)
301
- topPanel.value?.resize(vSide)
302
- bottomPanel.value?.resize(vSide)
464
+ iframeWidth.value = Math.min(device.width, rect.width - gutter)
465
+ iframeHeight.value = Math.min(device.height, rect.height - gutter)
466
+ updateFullSize()
303
467
  }
304
468
 
305
469
  watch(() => props.device, (device) => {
306
470
  if (viewMode.value === 'source') return
307
- applyDeviceSize(device)
471
+ // Only apply when a device is selected, not when cleared (drag start clears device)
472
+ if (device) applyDeviceSize(device)
308
473
  })
309
474
 
310
475
  watch(() => props.resetKey, () => {
@@ -339,9 +504,9 @@ function forwardIframeKeys(iframe: HTMLIFrameElement) {
339
504
  }
340
505
 
341
506
  onMounted(() => {
342
- const el = iframeEl.value
343
- if (el) {
344
- const rect = el.getBoundingClientRect()
507
+ const wrapper = wrapperEl.value
508
+ if (wrapper) {
509
+ const rect = wrapper.getBoundingClientRect()
345
510
  panelWidth.value = Math.round(rect.width)
346
511
  panelHeight.value = Math.round(rect.height)
347
512
  observer = new ResizeObserver((entries) => {
@@ -349,8 +514,13 @@ onMounted(() => {
349
514
  panelWidth.value = Math.round(entry.contentRect.width)
350
515
  panelHeight.value = Math.round(entry.contentRect.height)
351
516
  }
517
+ updateIframeContentHeight()
352
518
  })
353
- observer.observe(el)
519
+ observer.observe(wrapper)
520
+ }
521
+
522
+ const el = iframeEl.value
523
+ if (el) {
354
524
  el.addEventListener('load', () => forwardIframeKeys(el))
355
525
  }
356
526
  })
@@ -366,7 +536,7 @@ const activeTab = ref<string | undefined>(undefined)
366
536
  function toggleBottomPanel() {
367
537
  bottomPanelOpen.value = !bottomPanelOpen.value
368
538
  if (bottomPanelOpen.value) {
369
- tabsPanelHeight.value = 200
539
+ tabsPanelHeight.value = 300
370
540
  if (!activeTab.value) activeTab.value = 'compatibility'
371
541
  } else {
372
542
  tabsPanelHeight.value = 40
@@ -384,7 +554,7 @@ function onTabClick(tab: string) {
384
554
  activeTab.value = tab
385
555
  if (!bottomPanelOpen.value) {
386
556
  bottomPanelOpen.value = true
387
- tabsPanelHeight.value = 200
557
+ tabsPanelHeight.value = 300
388
558
  }
389
559
  }
390
560
 
@@ -396,8 +566,11 @@ function onTabsDragStart(e: MouseEvent) {
396
566
  const startY = e.clientY
397
567
  const startHeight = tabsPanelHeight.value
398
568
 
569
+ const rootEl = containerEl.value?.closest('.relative.h-full') as HTMLElement | null
570
+ const maxHeight = rootEl ? rootEl.getBoundingClientRect().height : Infinity
571
+
399
572
  const onMouseMove = (e: MouseEvent) => {
400
- const newHeight = Math.max(40, startHeight + startY - e.clientY)
573
+ const newHeight = Math.max(40, Math.min(maxHeight, startHeight + startY - e.clientY))
401
574
  tabsPanelHeight.value = newHeight
402
575
  bottomPanelOpen.value = newHeight > 40
403
576
 
@@ -426,187 +599,298 @@ const stripeBg = {
426
599
  </script>
427
600
 
428
601
  <template>
429
- <div class="flex flex-col h-full">
430
- <div class="relative flex-1 min-h-0">
602
+ <div class="relative h-full">
603
+ <div class="absolute inset-0 bottom-10 overflow-hidden">
431
604
  <!-- Source code view -->
432
605
  <div v-show="viewMode === 'source'" class="absolute inset-0 min-w-0 overflow-hidden">
433
606
  <div class="absolute top-3 left-6 z-10">
434
607
  <DropdownMenu :modal="false">
435
- <DropdownMenuTrigger class="inline-flex items-center gap-1 rounded-md bg-white/10 px-2.5 h-7 text-xs font-medium text-gray-300 hover:bg-white/15 transition-colors">
608
+ <DropdownMenuTrigger class="inline-flex items-center gap-1 rounded-md bg-[#27212e]/80 dark:bg-gray-950/80 backdrop-blur-md border border-white/10 px-2.5 h-7 text-xs font-medium text-gray-300 hover:bg-[#27212e] dark:hover:bg-gray-950 transition-colors">
436
609
  {{ sourceView === 'compiled' ? 'HTML' : sourceView === 'vue' ? 'Source' : 'Plaintext' }}
437
610
  <ChevronDown class="size-3 opacity-50" />
438
611
  </DropdownMenuTrigger>
439
- <DropdownMenuContent align="start" class="min-w-0 bg-white/10 backdrop-blur-md border-white/10">
440
- <DropdownMenuItem class="text-xs font-medium text-gray-300 hover:text-white focus:bg-white/10 focus:text-white" @click="sourceView = 'vue'">
441
- <Check v-if="sourceView === 'vue'" class="size-3.5" />
442
- <span :class="sourceView === 'vue' ? '' : 'pl-5.5'">Source</span>
612
+ <DropdownMenuContent align="start" class="min-w-32 bg-[#27212e]/80 dark:bg-gray-950/80 backdrop-blur-md border-white/10">
613
+ <DropdownMenuItem class="text-xs font-medium text-gray-400 focus:text-gray-200 focus:bg-white/10" @click="sourceView = 'vue'">
614
+ <Check v-if="sourceView === 'vue'" class="size-3 text-gray-200" />
615
+ <span :class="[sourceView === 'vue' ? 'text-gray-200' : 'pl-5']">Source</span>
443
616
  </DropdownMenuItem>
444
- <DropdownMenuItem class="text-xs font-medium text-gray-300 hover:text-white focus:bg-white/10 focus:text-white" @click="sourceView = 'compiled'">
445
- <Check v-if="sourceView === 'compiled'" class="size-3.5" />
446
- <span :class="sourceView === 'compiled' ? '' : 'pl-5.5'">HTML</span>
617
+ <DropdownMenuItem class="text-xs font-medium text-gray-400 focus:text-gray-200 focus:bg-white/10" @click="sourceView = 'compiled'">
618
+ <Check v-if="sourceView === 'compiled'" class="size-3 text-gray-200" />
619
+ <span :class="[sourceView === 'compiled' ? 'text-gray-200' : 'pl-5']">HTML</span>
447
620
  </DropdownMenuItem>
448
- <DropdownMenuItem class="text-xs font-medium text-gray-300 hover:text-white focus:bg-white/10 focus:text-white" @click="sourceView = 'plaintext'">
449
- <Check v-if="sourceView === 'plaintext'" class="size-3.5" />
450
- <span :class="sourceView === 'plaintext' ? '' : 'pl-5.5'">Plaintext</span>
621
+ <DropdownMenuItem class="text-xs font-medium text-gray-400 focus:text-gray-200 focus:bg-white/10" @click="sourceView = 'plaintext'">
622
+ <Check v-if="sourceView === 'plaintext'" class="size-3 text-gray-200" />
623
+ <span :class="[sourceView === 'plaintext' ? 'text-gray-200' : 'pl-5']">Plaintext</span>
451
624
  </DropdownMenuItem>
452
625
  </DropdownMenuContent>
453
626
  </DropdownMenu>
454
627
  </div>
455
628
  <button
456
- class="absolute top-3 right-6 z-10 inline-flex items-center justify-center rounded-md px-2.5 h-8 bg-transparent hover:bg-transparent group disabled:opacity-50 disabled:cursor-not-allowed transition-all"
629
+ class="absolute top-3 right-[26px] z-10 inline-flex items-center justify-center rounded-md size-7 bg-[#27212e]/80 dark:bg-gray-950/80 backdrop-blur-md border border-white/10 hover:bg-[#27212e] dark:hover:bg-gray-950 group disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
457
630
  :disabled="copied"
458
631
  @click="copySource"
459
632
  >
460
- <svg v-if="!copied" class="size-5 text-gray-400 group-hover:text-gray-300" viewBox="0 0 18 18" fill="none" xmlns="http://www.w3.org/2000/svg"><path d="M14.25 5.25H7.25C6.14543 5.25 5.25 6.14543 5.25 7.25V14.25C5.25 15.3546 6.14543 16.25 7.25 16.25H14.25C15.3546 16.25 16.25 15.3546 16.25 14.25V7.25C16.25 6.14543 15.3546 5.25 14.25 5.25Z" stroke="currentColor" stroke-width="1" stroke-linecap="round" stroke-linejoin="round" /><path d="M2.80103 11.998L1.77203 5.07397C1.61003 3.98097 2.36403 2.96397 3.45603 2.80197L10.38 1.77297C11.313 1.63397 12.19 2.16297 12.528 3.00097" stroke="currentColor" stroke-width="1" stroke-linecap="round" stroke-linejoin="round" /></svg>
461
- <svg v-else class="size-5 text-emerald-400" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M20 6 9 17l-5-5" /></svg>
633
+ <svg v-if="!copied" class="size-3.5 text-gray-400 group-hover:text-gray-300" viewBox="0 0 18 18" fill="none" xmlns="http://www.w3.org/2000/svg"><path d="M14.25 5.25H7.25C6.14543 5.25 5.25 6.14543 5.25 7.25V14.25C5.25 15.3546 6.14543 16.25 7.25 16.25H14.25C15.3546 16.25 16.25 15.3546 16.25 14.25V7.25C16.25 6.14543 15.3546 5.25 14.25 5.25Z" stroke="currentColor" stroke-width="1" stroke-linecap="round" stroke-linejoin="round" /><path d="M2.80103 11.998L1.77203 5.07397C1.61003 3.98097 2.36403 2.96397 3.45603 2.80197L10.38 1.77297C11.313 1.63397 12.19 2.16297 12.528 3.00097" stroke="currentColor" stroke-width="1" stroke-linecap="round" stroke-linejoin="round" /></svg>
634
+ <svg v-else class="size-3.5 text-emerald-400" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M20 6 9 17l-5-5" /></svg>
462
635
  </button>
463
- <div
464
- v-show="sourceView === 'compiled'"
465
- class="shiki-line-numbers h-full overflow-auto [&_pre]:p-6 [&_pre]:pt-14 [&_pre]:text-base [&_pre]:leading-6 [&_pre]:min-h-full [&_pre]:overflow-x-auto"
466
- v-html="sourceHtml"
467
- />
468
- <div
469
- ref="vueSourceEl"
470
- v-show="sourceView === 'vue'"
471
- class="shiki-line-numbers h-full overflow-auto [&_pre]:p-6 [&_pre]:pt-14 [&_pre]:text-base [&_pre]:leading-6 [&_pre]:min-h-full [&_pre]:overflow-x-auto"
472
- v-html="vueSourceHtml"
473
- />
474
- <pre
475
- v-show="sourceView === 'plaintext'"
476
- class="h-full overflow-auto p-6 pt-14 text-sm leading-6 min-h-full text-gray-300 bg-[#27212e] whitespace-pre-wrap break-words"
477
- >{{ plaintextContent }}</pre>
636
+ <ScrollArea v-show="sourceView === 'compiled'" class="h-full [&_[data-slot=scroll-area-viewport]>div]:flex [&_[data-slot=scroll-area-viewport]>div]:flex-col [&_[data-slot=scroll-area-viewport]>div]:min-h-full">
637
+ <div
638
+ ref="compiledSourceEl"
639
+ class="flex-1 bg-[#27212e] dark:bg-gray-950 shiki-line-numbers [&_pre]:p-6 [&_pre]:pt-14 [&_pre]:text-base [&_pre]:leading-6 [&_pre]:min-h-full dark:[&_pre]:bg-gray-950!"
640
+ v-html="sourceHtml"
641
+ />
642
+ <ScrollBar orientation="horizontal" />
643
+ </ScrollArea>
644
+ <ScrollArea v-show="sourceView === 'vue'" class="h-full [&_[data-slot=scroll-area-viewport]>div]:flex [&_[data-slot=scroll-area-viewport]>div]:flex-col [&_[data-slot=scroll-area-viewport]>div]:min-h-full">
645
+ <div
646
+ ref="vueSourceEl"
647
+ class="flex-1 bg-[#27212e] dark:bg-gray-950 shiki-line-numbers [&_pre]:p-6 [&_pre]:pt-14 [&_pre]:text-base [&_pre]:leading-6 [&_pre]:min-h-full dark:[&_pre]:bg-gray-950!"
648
+ v-html="vueSourceHtml"
649
+ />
650
+ <ScrollBar orientation="horizontal" />
651
+ </ScrollArea>
652
+ <ScrollArea v-show="sourceView === 'plaintext'" class="h-full [&_[data-slot=scroll-area-viewport]>div]:flex [&_[data-slot=scroll-area-viewport]>div]:flex-col [&_[data-slot=scroll-area-viewport]>div]:min-h-full">
653
+ <pre
654
+ class="p-6 pt-14 text-sm leading-6 flex-1 text-gray-300 bg-[#27212e] dark:bg-gray-950 whitespace-pre-wrap break-words"
655
+ >{{ plaintextContent }}</pre>
656
+ </ScrollArea>
478
657
  </div>
479
658
 
659
+ <!-- Blocks iframe from stealing pointer events while dragging tabs -->
660
+ <div v-if="tabsDragging" class="fixed inset-0 z-50" />
661
+
480
662
  <!-- Preview view -->
481
663
  <div v-show="viewMode !== 'source'" class="absolute inset-0">
482
664
  <div class="relative h-full opacity-5" :style="stripeBg" />
483
665
  </div>
484
666
 
485
- <div v-show="viewMode !== 'source'" ref="containerEl" class="absolute inset-0 z-10 flex flex-col">
486
- <div class="flex-1 min-h-0">
487
- <ResizablePanelGroup direction="vertical" class="h-full" @layout="onVerticalLayout">
488
- <ResizablePanel ref="topPanel" :default-size="0" @resize="(s: number) => { sideSizes.top = s; updateFullSize() }" />
489
- <ResizableHandle class="h-4! bg-gray-50 hover:bg-gray-100 dark:bg-white/5 dark:hover:bg-white/10 transition-colors after:hidden!" @dragging="(v: boolean) => v ? onVDragStart() : onVDragEnd()" />
490
- <ResizablePanel :default-size="100" :min-size="20">
491
- <ResizablePanelGroup direction="horizontal" class="h-full" @layout="onHorizontalLayout">
492
- <ResizablePanel ref="leftPanel" :default-size="0" @resize="(s: number) => { sideSizes.left = s; updateFullSize() }" />
493
- <ResizableHandle class="w-4 bg-gray-50 hover:bg-gray-100 dark:bg-white/5 dark:hover:bg-white/10 transition-colors after:hidden!" @dragging="(v: boolean) => v ? onHDragStart() : onHDragEnd()" />
494
- <ResizablePanel ref="previewEl" :default-size="100" :min-size="20">
495
- <iframe
496
- ref="iframeEl"
497
- :srcdoc="srcdoc"
498
- class="h-full w-full border-0 bg-white"
499
- />
500
- </ResizablePanel>
501
- <ResizableHandle class="w-4 bg-gray-50 hover:bg-gray-100 dark:bg-white/5 dark:hover:bg-white/10 transition-colors after:hidden!" @dragging="(v: boolean) => v ? onHDragStart() : onHDragEnd()" />
502
- <ResizablePanel ref="rightPanel" :default-size="0" @resize="(s: number) => { sideSizes.right = s; updateFullSize() }" />
503
- </ResizablePanelGroup>
504
- </ResizablePanel>
505
- <ResizableHandle class="h-4! bg-gray-50 hover:bg-gray-100 dark:bg-white/5 dark:hover:bg-white/10 transition-colors after:hidden!" @dragging="(v: boolean) => v ? onVDragStart() : onVDragEnd()" />
506
- <ResizablePanel ref="bottomPanel" :default-size="0" @resize="(s: number) => { sideSizes.bottom = s; updateFullSize() }" />
507
- </ResizablePanelGroup>
667
+ <div v-show="viewMode !== 'source'" ref="containerEl" class="absolute inset-0 z-10 flex items-center justify-center">
668
+ <!-- Blocks iframe from stealing pointer events while dragging -->
669
+ <div v-if="isDragging" class="absolute inset-0 z-20" />
670
+ <div
671
+ class="relative"
672
+ :style="{
673
+ width: iframeWidth != null ? `${iframeWidth + 40}px` : '100%',
674
+ height: iframeHeight != null ? `${iframeHeight + 40}px` : '100%',
675
+ transition: isDragging ? 'none' : 'width 0.2s ease, height 0.2s ease',
676
+ }"
677
+ >
678
+ <!-- Top handle -->
679
+ <div class="group hidden min-[430px]:flex absolute top-0 left-5 right-5 h-5 items-center justify-center cursor-ns-resize" @mousedown="onEdgeDrag($event, 'top')" @touchstart.prevent="onEdgeDrag($event, 'top')">
680
+ <div class="h-1 w-12 rounded-full bg-gray-300 dark:bg-gray-600 group-hover:bg-gray-400 group-active:bg-gray-500 dark:group-hover:bg-gray-500 dark:group-active:bg-gray-400 transition-colors" />
681
+ </div>
682
+ <!-- Bottom handle -->
683
+ <div class="group hidden min-[430px]:flex absolute bottom-0 left-5 right-5 h-5 items-center justify-center cursor-ns-resize" @mousedown="onEdgeDrag($event, 'bottom')" @touchstart.prevent="onEdgeDrag($event, 'bottom')">
684
+ <div class="h-1 w-12 rounded-full bg-gray-300 dark:bg-gray-600 group-hover:bg-gray-400 group-active:bg-gray-500 dark:group-hover:bg-gray-500 dark:group-active:bg-gray-400 transition-colors" />
685
+ </div>
686
+ <!-- Left handle -->
687
+ <div class="group hidden min-[430px]:flex absolute left-0 top-5 bottom-5 w-5 items-center justify-center cursor-ew-resize" @mousedown="onEdgeDrag($event, 'left')" @touchstart.prevent="onEdgeDrag($event, 'left')">
688
+ <div class="w-1 h-12 rounded-full bg-gray-300 dark:bg-gray-600 group-hover:bg-gray-400 group-active:bg-gray-500 dark:group-hover:bg-gray-500 dark:group-active:bg-gray-400 transition-colors" />
689
+ </div>
690
+ <!-- Right handle -->
691
+ <div class="group hidden min-[430px]:flex absolute right-0 top-5 bottom-5 w-5 items-center justify-center cursor-ew-resize" @mousedown="onEdgeDrag($event, 'right')" @touchstart.prevent="onEdgeDrag($event, 'right')">
692
+ <div class="w-1 h-12 rounded-full bg-gray-300 dark:bg-gray-600 group-hover:bg-gray-400 group-active:bg-gray-500 dark:group-hover:bg-gray-500 dark:group-active:bg-gray-400 transition-colors" />
693
+ </div>
694
+ <!-- Iframe -->
695
+ <div ref="wrapperEl" class="absolute inset-0 min-[430px]:inset-5 border border-gray-200 dark:border-gray-800">
696
+ <ScrollArea class="h-full w-full bg-white dark:bg-gray-950">
697
+ <iframe
698
+ ref="iframeEl"
699
+ :srcdoc="srcdoc"
700
+ @load="updateIframeContentHeight"
701
+ class="w-full border-0 bg-white dark:bg-gray-950"
702
+ :style="{ height: iframeContentHeight ? `${iframeContentHeight}px` : '100%' }"
703
+ />
704
+ </ScrollArea>
705
+ </div>
508
706
  </div>
509
707
  </div>
510
708
  </div>
511
709
 
512
- <!-- Tabs panel (always visible) -->
710
+ <!-- Tabs panel (overlay) -->
513
711
  <div
514
- class="shrink-0 bg-white dark:bg-gray-950 overflow-hidden"
515
- :class="!tabsDragging ? 'transition-[height] duration-200 ease-in-out' : ''"
712
+ class="absolute bottom-0 left-0 right-0 z-20 overflow-hidden border-t border-gray-200 dark:border-gray-800/50"
713
+ :class="[
714
+ !tabsDragging ? 'transition-[height] duration-200 ease-in-out' : '',
715
+ 'bg-white dark:bg-gray-950',
716
+ ]"
516
717
  :style="{ height: `${tabsPanelHeight}px` }"
517
718
  >
518
719
  <div
519
- class="relative h-px bg-gray-200 dark:bg-gray-800 cursor-row-resize before:absolute before:-top-2 before:left-0 before:right-0 before:h-5 before:content-['']"
720
+ class="relative h-0 cursor-row-resize before:absolute before:top-0 before:left-0 before:right-0 before:h-3.25 before:content-['']"
520
721
  @mousedown="onTabsDragStart"
521
722
  />
522
723
  <Tabs :model-value="activeTab" class="flex flex-col min-h-0 h-full">
523
- <div class="flex items-center justify-between min-h-10 px-4 shrink-0" :class="bottomPanelOpen ? 'border-b' : ''">
724
+ <div class="flex items-center justify-between min-h-10 pl-2 pr-3 shrink-0" :class="bottomPanelOpen ? 'border-b' : ''">
524
725
  <TabsList class="h-full bg-transparent! rounded-none! p-0 gap-1">
525
- <TabsTrigger value="compatibility" class="text-xs px-3 h-full rounded-none! border-0! shadow-none! border-b! border-transparent data-[state=active]:border-gray-400 data-[state=active]:dark:border-gray-600 data-[state=active]:bg-transparent data-[state=inactive]:bg-transparent" @click="onTabClick('compatibility')">
726
+ <TabsTrigger value="compatibility" class="text-xs font-normal px-3 h-full rounded-none! border-0! shadow-none! border-b! border-transparent select-none data-[state=active]:border-gray-400 data-[state=active]:dark:border-gray-600 data-[state=active]:bg-transparent data-[state=inactive]:bg-transparent" @click="onTabClick('compatibility')">
526
727
  Compatibility
527
728
  </TabsTrigger>
528
- <TabsTrigger value="lint" class="text-xs px-3 h-full rounded-none! border-0! shadow-none! border-b! border-transparent data-[state=active]:border-gray-400 data-[state=active]:dark:border-gray-600 data-[state=active]:bg-transparent data-[state=inactive]:bg-transparent" @click="onTabClick('lint')">
729
+ <TabsTrigger value="lint" class="text-xs font-normal px-3 h-full rounded-none! border-0! shadow-none! border-b! border-transparent select-none data-[state=active]:border-gray-400 data-[state=active]:dark:border-gray-600 data-[state=active]:bg-transparent data-[state=inactive]:bg-transparent" @click="onTabClick('lint')">
529
730
  Linter
530
731
  </TabsTrigger>
531
- <TabsTrigger value="stats" class="text-xs px-3 h-full rounded-none! border-0! shadow-none! border-b! border-transparent data-[state=active]:border-gray-400 data-[state=active]:dark:border-gray-600 data-[state=active]:bg-transparent data-[state=inactive]:bg-transparent" @click="onTabClick('stats')">
732
+ <TabsTrigger value="stats" class="text-xs font-normal px-3 h-full rounded-none! border-0! shadow-none! border-b! border-transparent select-none data-[state=active]:border-gray-400 data-[state=active]:dark:border-gray-600 data-[state=active]:bg-transparent data-[state=inactive]:bg-transparent" @click="onTabClick('stats')">
532
733
  Stats
533
734
  </TabsTrigger>
735
+ <TabsTrigger value="test" class="text-xs font-normal px-3 h-full rounded-none! border-0! shadow-none! border-b! border-transparent select-none data-[state=active]:border-gray-400 data-[state=active]:dark:border-gray-600 data-[state=active]:bg-transparent data-[state=inactive]:bg-transparent" @click="onTabClick('test')">
736
+ Test
737
+ </TabsTrigger>
534
738
  </TabsList>
535
739
  <Button variant="ghost" size="icon" class="h-7 w-7 hover:bg-transparent!" @click="toggleBottomPanel">
536
- <ChevronUp v-if="!bottomPanelOpen" class="size-4" />
537
- <ChevronDown v-else class="size-4" />
740
+ <ChevronUp v-if="!bottomPanelOpen" class="size-4 dark:text-gray-400" :stroke-width="1" />
741
+ <ChevronDown v-else class="size-4 dark:text-gray-400" :stroke-width="1" />
538
742
  </Button>
539
743
  </div>
540
- <div class="flex-1 overflow-auto">
541
- <TabsContent value="compatibility" class="mt-0">
542
- <p v-if="compatibilityLoading" class="px-4 py-3 text-xs text-gray-500 dark:text-gray-400">Checking compatibility...</p>
543
- <p v-else-if="compatibilityIssues.length === 0" class="px-4 py-3 text-xs text-gray-500 dark:text-gray-400">No compatibility issues found.</p>
544
- <ul v-else class="text-xs divide-y">
545
- <li
546
- v-for="(issue, i) in compatibilityIssues"
547
- :key="i"
548
- class="px-4 py-2 hover:bg-gray-50 dark:hover:bg-white/5"
744
+ <div class="flex-1 min-h-0">
745
+ <TabsContent value="compatibility" class="mt-0 h-full flex flex-col"><div v-if="!compatibilityLoading && !compatibilityError && compatibilityIssues.length > 0" class="flex gap-1 pl-3 pr-4 py-2 border-b border-gray-200 dark:border-white/10 shrink-0">
746
+ <button
747
+ v-for="cat in activeCompatibilityCategories"
748
+ :key="cat"
749
+ class="px-2 py-0.5 text-[11px] rounded-full cursor-pointer transition-colors"
750
+ :class="compatibilityCategory === cat
751
+ ? 'bg-gray-900 text-white dark:bg-gray-600 dark:text-gray-100'
752
+ : 'text-gray-500 dark:text-gray-400 hover:bg-gray-100 dark:hover:bg-white/10'"
753
+ @click="compatibilityCategory = cat"
549
754
  >
550
- <div class="flex items-start justify-between gap-4">
551
- <div>
552
- <a v-if="issue.url" :href="issue.url" target="_blank" rel="noopener" class="font-medium hover:underline" :class="issue.type === 'error' ? 'text-red-600' : 'text-amber-600'">
755
+ {{ cat === 'css' ? 'CSS' : cat === 'html' ? 'HTML' : cat.charAt(0).toUpperCase() + cat.slice(1) }}
756
+ <span class="ml-0.5 tabular-nums">{{ compatibilityIssues.filter(i => i.category === cat).length }}</span>
757
+ </button>
758
+ </div>
759
+ <ScrollArea class="h-full flex-1 min-h-0 pl-5">
760
+ <p v-if="compatibilityLoading" class="pr-4 py-3 text-xs text-gray-500 dark:text-gray-400">Checking compatibility...</p>
761
+ <p v-else-if="compatibilityError" class="pr-4 py-3 text-xs text-red-500 dark:text-red-400">{{ compatibilityError }}</p>
762
+ <p v-else-if="compatibilityIssues.length === 0" class="pr-4 py-3 text-xs text-gray-500 dark:text-gray-400">No compatibility issues found.</p>
763
+ <ul v-else class="text-xs divide-y">
764
+ <li
765
+ v-for="(issue, i) in filteredCompatibilityIssues"
766
+ :key="i"
767
+ class="pr-4 py-1.5 hover:bg-gray-50 dark:hover:bg-white/5"
768
+ >
769
+ <div class="flex items-center gap-2">
770
+ <a v-if="issue.url" :href="issue.url" target="_blank" rel="noopener" class="font-medium hover:underline shrink-0" :class="issue.type === 'error' ? 'text-red-600' : 'text-amber-600'">
553
771
  {{ issue.title }}
554
772
  </a>
555
- <span v-else class="font-medium" :class="issue.type === 'error' ? 'text-red-600' : 'text-amber-600'">
773
+ <span v-else class="font-medium shrink-0" :class="issue.type === 'error' ? 'text-red-600' : 'text-amber-600'">
556
774
  {{ issue.title }}
557
775
  </span>
558
- <div class="text-gray-500 dark:text-gray-400 mt-1 space-y-0.5">
559
- <div v-for="client in issue.clients" :key="client.name">
560
- <span class="text-gray-700 dark:text-gray-300">{{ client.name }}</span><span v-if="client.notes.length">: {{ client.notes.join('. ') }}</span>
561
- </div>
776
+ <span class="text-gray-400 dark:text-gray-600 shrink-0">&middot;</span>
777
+ <span class="text-gray-500 dark:text-gray-400 truncate">{{ issue.type === 'error' ? 'Not supported' : 'Partial support' }} in {{ issue.clients.map((c: any) => c.name).join(', ') }}</span>
778
+ <button v-if="issue.line" class="text-gray-500 dark:text-gray-400 hover:text-gray-900 dark:hover:text-gray-100 cursor-pointer tabular-nums shrink-0 ml-auto" @click="goToCompiledLine(issue.line!)">L{{ issue.line }}</button>
779
+ </div>
780
+ </li>
781
+ </ul>
782
+ </ScrollArea>
783
+ </TabsContent>
784
+ <TabsContent value="lint" class="mt-0 h-full">
785
+ <ScrollArea class="h-full pl-5">
786
+ <p v-if="lintLoading" class="pr-4 py-3 text-xs text-gray-500 dark:text-gray-400">Linting...</p>
787
+ <p v-else-if="lintIssues.length === 0" class="pr-4 py-3 text-xs text-gray-500 dark:text-gray-400">No issues found.</p>
788
+ <ul v-else class="text-xs divide-y">
789
+ <li
790
+ v-for="(issue, i) in lintIssues"
791
+ :key="i"
792
+ class="pr-4 py-2 hover:bg-gray-50 dark:hover:bg-white/5"
793
+ >
794
+ <div class="flex items-start justify-between gap-4">
795
+ <div>
796
+ <span class="font-medium" :class="issue.type === 'error' ? 'text-red-600' : 'text-amber-600'">
797
+ {{ issue.title }}
798
+ </span>
799
+ <div class="text-gray-500 dark:text-gray-400 mt-0.5">{{ issue.message }}</div>
562
800
  </div>
801
+ <button v-if="issue.line" class="text-gray-500 dark:text-gray-400 hover:text-gray-900 dark:hover:text-gray-100 cursor-pointer tabular-nums shrink-0" @click="goToLine(issue.line!)">L{{ issue.line }}</button>
563
802
  </div>
564
- <button v-if="issue.line" class="text-gray-500 dark:text-gray-400 hover:text-gray-900 dark:hover:text-gray-100 cursor-pointer tabular-nums shrink-0" @click="goToLine(issue.line!)">L{{ issue.line }}</button>
803
+ </li>
804
+ </ul>
805
+ </ScrollArea>
806
+ </TabsContent>
807
+ <TabsContent value="stats" class="mt-0 h-full">
808
+ <ScrollArea class="h-full pl-5">
809
+ <p v-if="statsLoading" class="pr-4 py-3 text-xs text-gray-500 dark:text-gray-400">Loading stats...</p>
810
+ <p v-else-if="!stats" class="pr-4 py-3 text-xs text-gray-500 dark:text-gray-400">No stats available.</p>
811
+ <div v-else class="pr-4 py-3 flex items-center gap-6 text-xs">
812
+ <div class="flex items-center gap-1.5">
813
+ <span class="text-gray-500 dark:text-gray-400">Size</span>
814
+ <span
815
+ class="font-medium tabular-nums"
816
+ :class="stats.size.bytes > 102400 ? 'text-red-600' : stats.size.bytes > 51200 ? 'text-amber-600' : 'text-gray-900 dark:text-gray-300'"
817
+ >{{ stats.size.formatted }}</span>
818
+ <TooltipProvider :delay-duration="0">
819
+ <Tooltip>
820
+ <TooltipTrigger as-child>
821
+ <button type="button">
822
+ <Info class="size-3 text-gray-400 dark:text-gray-500" />
823
+ </button>
824
+ </TooltipTrigger>
825
+ <TooltipContent class="max-w-60">
826
+ Compiled HTML size, excludes image files. Gmail clips content at ~100KB.
827
+ </TooltipContent>
828
+ </Tooltip>
829
+ </TooltipProvider>
565
830
  </div>
566
- </li>
567
- </ul>
831
+ <div class="flex items-center gap-1.5">
832
+ <span class="text-gray-500 dark:text-gray-400">Images</span>
833
+ <span class="font-medium tabular-nums">{{ stats.images }}</span>
834
+ </div>
835
+ <div class="flex items-center gap-1.5">
836
+ <span class="text-gray-500 dark:text-gray-400">Links</span>
837
+ <span class="font-medium tabular-nums">{{ stats.links }}</span>
838
+ </div>
839
+ </div>
840
+ </ScrollArea>
568
841
  </TabsContent>
569
- <TabsContent value="lint" class="mt-0">
570
- <p v-if="lintLoading" class="px-4 py-3 text-xs text-gray-500 dark:text-gray-400">Linting...</p>
571
- <p v-else-if="lintIssues.length === 0" class="px-4 py-3 text-xs text-gray-500 dark:text-gray-400">No issues found.</p>
572
- <ul v-else class="text-xs divide-y">
573
- <li
574
- v-for="(issue, i) in lintIssues"
575
- :key="i"
576
- class="px-4 py-2 hover:bg-gray-50 dark:hover:bg-white/5"
577
- >
578
- <div class="flex items-start justify-between gap-4">
579
- <div>
580
- <span class="font-medium" :class="issue.type === 'error' ? 'text-red-600' : 'text-amber-600'">
581
- {{ issue.title }}
582
- </span>
583
- <div class="text-gray-500 dark:text-gray-400 mt-0.5">{{ issue.message }}</div>
842
+ <TabsContent value="test" class="mt-0 h-full">
843
+ <ScrollArea class="h-full pl-5">
844
+ <div class="pr-4 py-3 max-w-md">
845
+ <div class="space-y-2">
846
+ <div class="flex items-center gap-2">
847
+ <label class="text-xs text-gray-500 dark:text-gray-400 w-12 shrink-0">To</label>
848
+ <TagsInput v-model="emailTo" delimiter=" " add-on-paste add-on-blur class="flex-1 min-h-7 gap-1 px-2 py-1">
849
+ <TagsInputItem v-for="item in emailTo" :key="item" :value="item" class="h-5 text-xs rounded">
850
+ <TagsInputItemText class="px-1.5 py-0 text-xs" />
851
+ <TagsInputItemDelete class="size-3.5" />
852
+ </TagsInputItem>
853
+ <TagsInputInput class="text-xs min-h-5 px-0.5" placeholder="Add emails..." />
854
+ </TagsInput>
855
+ </div>
856
+ <div class="flex items-center gap-2">
857
+ <label class="text-xs text-gray-500 dark:text-gray-400 w-12 shrink-0">Subject</label>
858
+ <div class="flex-1 flex items-center gap-3">
859
+ <Input v-model="emailSubject" :placeholder="String(route.params.template)" class="flex-1 h-7 text-xs! px-2" />
860
+ <label class="flex items-center gap-1.5 cursor-pointer select-none shrink-0">
861
+ <Checkbox v-model="emailPreventThreading" :default-checked="true" class="size-3.5" />
862
+ <span class="text-xs text-gray-500 dark:text-gray-400">Prevent threading</span>
863
+ </label>
864
+ </div>
584
865
  </div>
585
- <button v-if="issue.line" class="text-gray-500 dark:text-gray-400 hover:text-gray-900 dark:hover:text-gray-100 cursor-pointer tabular-nums shrink-0" @click="goToLine(issue.line!)">L{{ issue.line }}</button>
586
866
  </div>
587
- </li>
588
- </ul>
589
- </TabsContent>
590
- <TabsContent value="stats" class="mt-0">
591
- <p v-if="statsLoading" class="px-4 py-3 text-xs text-gray-500 dark:text-gray-400">Loading stats...</p>
592
- <p v-else-if="!stats" class="px-4 py-3 text-xs text-gray-500 dark:text-gray-400">No stats available.</p>
593
- <div v-else class="px-4 py-3 flex items-center gap-6 text-xs">
594
- <div class="flex items-center gap-1.5">
595
- <span class="text-gray-500 dark:text-gray-400">Size</span>
596
- <span
597
- class="font-medium tabular-nums"
598
- :class="stats.size.bytes > 102400 ? 'text-red-600' : stats.size.bytes > 51200 ? 'text-amber-600' : 'text-gray-900 dark:text-gray-100'"
599
- >{{ stats.size.formatted }}</span>
600
- </div>
601
- <div class="flex items-center gap-1.5">
602
- <span class="text-gray-500 dark:text-gray-400">Images</span>
603
- <span class="font-medium tabular-nums">{{ stats.images }}</span>
604
- </div>
605
- <div class="flex items-center gap-1.5">
606
- <span class="text-gray-500 dark:text-gray-400">Links</span>
607
- <span class="font-medium tabular-nums">{{ stats.links }}</span>
867
+ <div class="flex items-center gap-3 mt-3">
868
+ <Button
869
+ size="sm"
870
+ class="h-7 text-xs px-3"
871
+ :disabled="!emailTo.length || emailSending"
872
+ @click="sendTestEmail"
873
+ >
874
+ <svg v-if="emailSending" class="size-3.5 animate-spin [animation-duration:0.6s]" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"><circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4" /><path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z" /></svg>
875
+ {{ emailSending ? 'Sending' : 'Send' }}
876
+ </Button>
877
+ </div>
878
+ <div v-if="emailResult" class="mt-2">
879
+ <p class="text-xs" :class="emailResult.success ? 'text-gray-950 dark:text-white' : 'text-red-600'">
880
+ {{ emailResult.message }}
881
+ <a
882
+ v-if="emailResult.previewUrl"
883
+ :href="emailResult.previewUrl"
884
+ target="_blank"
885
+ rel="noopener"
886
+ class="text-gray-500 dark:text-gray-400 hover:underline"
887
+ >
888
+ (view)
889
+ </a>
890
+ </p>
891
+ </div>
608
892
  </div>
609
- </div>
893
+ </ScrollArea>
610
894
  </TabsContent>
611
895
  </div>
612
896
  </Tabs>