@maizzle/framework 6.0.0-rc.12 → 6.0.0-rc.14

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 (99) hide show
  1. package/dist/build.mjs +4 -1
  2. package/dist/build.mjs.map +1 -1
  3. package/dist/components/Button.vue +2 -2
  4. package/dist/components/CodeBlock.vue +2 -1
  5. package/dist/components/Column.vue +28 -22
  6. package/dist/components/Container.vue +47 -9
  7. package/dist/components/Font.vue +96 -0
  8. package/dist/components/Layout.vue +9 -4
  9. package/dist/components/Overlap.vue +75 -18
  10. package/dist/components/Row.vue +40 -19
  11. package/dist/components/Section.vue +35 -8
  12. package/dist/components/utils.d.mts +14 -1
  13. package/dist/components/utils.d.mts.map +1 -1
  14. package/dist/components/utils.mjs +32 -1
  15. package/dist/components/utils.mjs.map +1 -1
  16. package/dist/components/utils.ts +39 -0
  17. package/dist/composables/renderContext.d.mts +8 -1
  18. package/dist/composables/renderContext.d.mts.map +1 -1
  19. package/dist/composables/renderContext.mjs.map +1 -1
  20. package/dist/composables/useFont.d.mts +50 -0
  21. package/dist/composables/useFont.d.mts.map +1 -0
  22. package/dist/composables/useFont.mjs +93 -0
  23. package/dist/composables/useFont.mjs.map +1 -0
  24. package/dist/index.d.mts +2 -1
  25. package/dist/index.mjs +2 -1
  26. package/dist/plugins/postcss/quoteFontFamilies.d.mts +13 -0
  27. package/dist/plugins/postcss/quoteFontFamilies.d.mts.map +1 -0
  28. package/dist/plugins/postcss/quoteFontFamilies.mjs +84 -0
  29. package/dist/plugins/postcss/quoteFontFamilies.mjs.map +1 -0
  30. package/dist/render/createRenderer.mjs +8 -2
  31. package/dist/render/createRenderer.mjs.map +1 -1
  32. package/dist/render/injectFonts.d.mts +15 -0
  33. package/dist/render/injectFonts.d.mts.map +1 -0
  34. package/dist/render/injectFonts.mjs +46 -0
  35. package/dist/render/injectFonts.mjs.map +1 -0
  36. package/dist/serve.d.mts.map +1 -1
  37. package/dist/serve.mjs +28 -12
  38. package/dist/serve.mjs.map +1 -1
  39. package/dist/server/compatibility.d.mts +54 -2
  40. package/dist/server/compatibility.d.mts.map +1 -1
  41. package/dist/server/compatibility.mjs +890 -76
  42. package/dist/server/compatibility.mjs.map +1 -1
  43. package/dist/server/linter.d.mts +15 -2
  44. package/dist/server/linter.d.mts.map +1 -1
  45. package/dist/server/linter.mjs +194 -43
  46. package/dist/server/linter.mjs.map +1 -1
  47. package/dist/server/sfc-utils.d.mts +18 -0
  48. package/dist/server/sfc-utils.d.mts.map +1 -0
  49. package/dist/server/sfc-utils.mjs +184 -0
  50. package/dist/server/sfc-utils.mjs.map +1 -0
  51. package/dist/server/ui/App.vue +27 -50
  52. package/dist/server/ui/components/SidebarClose.vue +12 -0
  53. package/dist/server/ui/components/ui/command/Command.vue +1 -0
  54. package/dist/server/ui/components/ui/input/Input.vue +1 -1
  55. package/dist/server/ui/components/ui/sidebar/SidebarTrigger.vue +1 -1
  56. package/dist/server/ui/components/ui/tags-input/TagsInputInput.vue +1 -1
  57. package/dist/server/ui/lib/emulated-dark-mode.ts +131 -0
  58. package/dist/server/ui/pages/Preview.vue +215 -156
  59. package/dist/transformers/addAttributes.mjs +10 -6
  60. package/dist/transformers/addAttributes.mjs.map +1 -1
  61. package/dist/transformers/columnWidth.d.mts +31 -0
  62. package/dist/transformers/columnWidth.d.mts.map +1 -0
  63. package/dist/transformers/columnWidth.mjs +166 -0
  64. package/dist/transformers/columnWidth.mjs.map +1 -0
  65. package/dist/transformers/index.d.mts.map +1 -1
  66. package/dist/transformers/index.mjs +4 -0
  67. package/dist/transformers/index.mjs.map +1 -1
  68. package/dist/transformers/inlineCSS.mjs +2 -2
  69. package/dist/transformers/inlineCSS.mjs.map +1 -1
  70. package/dist/transformers/msoWidthFromClass.d.mts +19 -0
  71. package/dist/transformers/msoWidthFromClass.d.mts.map +1 -0
  72. package/dist/transformers/msoWidthFromClass.mjs +61 -0
  73. package/dist/transformers/msoWidthFromClass.mjs.map +1 -0
  74. package/dist/transformers/purgeCSS.mjs +1 -1
  75. package/dist/transformers/purgeCSS.mjs.map +1 -1
  76. package/dist/transformers/tailwindcss.d.mts.map +1 -1
  77. package/dist/transformers/tailwindcss.mjs +6 -16
  78. package/dist/transformers/tailwindcss.mjs.map +1 -1
  79. package/dist/types/config.d.mts +42 -2
  80. package/dist/types/config.d.mts.map +1 -1
  81. package/dist/types/index.d.mts +2 -2
  82. package/dist/utils/decodeStyleEntities.d.mts +15 -0
  83. package/dist/utils/decodeStyleEntities.d.mts.map +1 -0
  84. package/dist/utils/decodeStyleEntities.mjs +18 -0
  85. package/dist/utils/decodeStyleEntities.mjs.map +1 -0
  86. package/package.json +2 -3
  87. package/dist/_virtual/_rolldown/runtime.mjs +0 -32
  88. package/dist/node_modules/picomatch/index.mjs +0 -13
  89. package/dist/node_modules/picomatch/index.mjs.map +0 -1
  90. package/dist/node_modules/picomatch/lib/constants.mjs +0 -174
  91. package/dist/node_modules/picomatch/lib/constants.mjs.map +0 -1
  92. package/dist/node_modules/picomatch/lib/parse.mjs +0 -1067
  93. package/dist/node_modules/picomatch/lib/parse.mjs.map +0 -1
  94. package/dist/node_modules/picomatch/lib/picomatch.mjs +0 -304
  95. package/dist/node_modules/picomatch/lib/picomatch.mjs.map +0 -1
  96. package/dist/node_modules/picomatch/lib/scan.mjs +0 -296
  97. package/dist/node_modules/picomatch/lib/scan.mjs.map +0 -1
  98. package/dist/node_modules/picomatch/lib/utils.mjs +0 -53
  99. package/dist/node_modules/picomatch/lib/utils.mjs.map +0 -1
@@ -1,7 +1,7 @@
1
1
  <script setup lang="ts">
2
2
  import { ref, computed, watch, nextTick, onMounted, onUnmounted } from 'vue'
3
3
  import { useRoute } from 'vue-router'
4
- import { ChevronUp, ChevronDown, Check, Info } from 'lucide-vue-next'
4
+ import { ChevronUp, ChevronDown, Check } from 'lucide-vue-next'
5
5
  import {
6
6
  DropdownMenu,
7
7
  DropdownMenuContent,
@@ -20,9 +20,9 @@ import {
20
20
  TagsInputItemDelete,
21
21
  TagsInputItemText,
22
22
  } from '@/components/ui/tags-input'
23
- import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip'
24
23
 
25
24
  import stripesUrl from '../stripes.svg'
25
+ import { applyColorInversion, undoColorInversion } from '@/lib/emulated-dark-mode'
26
26
 
27
27
  interface Device {
28
28
  name: string
@@ -43,6 +43,7 @@ const props = defineProps<{
43
43
  }>()
44
44
 
45
45
  const viewMode = defineModel<'preview' | 'source'>('viewMode', { default: 'preview' })
46
+ const darkMode = defineModel<boolean>('darkMode', { default: false })
46
47
 
47
48
  const route = useRoute()
48
49
  const srcdoc = ref('')
@@ -71,7 +72,9 @@ const iframeContentHeight = ref<number | null>(null)
71
72
  function copySource() {
72
73
  let text: string
73
74
  if (sourceView.value === 'compiled') {
74
- text = srcdoc.value
75
+ // `renderedHtml` holds the raw compiled HTML (srcdoc is only populated
76
+ // for the initial iframe load; subsequent renders use doc.write).
77
+ text = renderedHtml || srcdoc.value
75
78
  } else if (sourceView.value === 'plaintext') {
76
79
  text = plaintextContent.value
77
80
  } else {
@@ -80,28 +83,59 @@ function copySource() {
80
83
  text = el.textContent || ''
81
84
  }
82
85
 
83
- const blob = new Blob([text], { type: 'text/plain' })
84
- const item = new ClipboardItem({ 'text/plain': blob })
85
- navigator.clipboard.write([item]).then(() => {
86
+ navigator.clipboard.writeText(text).then(() => {
86
87
  copied.value = true
87
88
  setTimeout(() => { copied.value = false }, 2000)
89
+ }).catch((err) => {
90
+ console.error('Copy failed:', err)
88
91
  })
89
92
  }
90
93
 
91
- interface CompatibilityIssue {
92
- type: 'error' | 'warning'
94
+ interface CheckIssue {
95
+ kind: 'compat' | 'lint'
96
+ slug?: string
93
97
  title: string
94
- category: string
95
- clients: Array<{ name: string, notes: string[] }>
96
98
  url?: string
99
+ category: string
97
100
  line?: number
101
+ file: string
102
+ // compat-only
103
+ supportLevel?: 'unsupported' | 'mitigated' | 'unknown'
104
+ supportLabel?: string
105
+ affectedClients?: string[]
106
+ // lint-only
107
+ severity?: 'error' | 'warning'
108
+ message?: string
98
109
  }
99
110
 
100
- interface LintIssue {
101
- type: 'error' | 'warning'
102
- title: string
103
- message: string
104
- line?: number
111
+ function supportPrefix(issue: CheckIssue): string {
112
+ if (issue.supportLevel === 'unsupported') return 'Not supported in'
113
+ if (issue.supportLevel === 'mitigated') return 'Partial support in'
114
+ return 'Support unknown in'
115
+ }
116
+
117
+ /**
118
+ * Split a message on backtick-delimited code spans. Returns alternating
119
+ * { text } and { code } segments so the template can render <code> inline
120
+ * without needing v-html.
121
+ */
122
+ function messageSegments(raw: string | undefined): Array<{ code: boolean, text: string }> {
123
+ if (!raw) return []
124
+ const out: Array<{ code: boolean, text: string }> = []
125
+ const parts = raw.split('`')
126
+ for (let i = 0; i < parts.length; i++) {
127
+ if (parts[i]) out.push({ code: i % 2 === 1, text: parts[i] })
128
+ }
129
+ return out
130
+ }
131
+
132
+ function issueColorClass(issue: CheckIssue): string {
133
+ if (issue.kind === 'lint') {
134
+ return issue.severity === 'error' ? 'text-rose-600' : 'text-amber-600'
135
+ }
136
+ if (issue.supportLevel === 'unsupported') return 'text-rose-600'
137
+ if (issue.supportLevel === 'mitigated') return 'text-amber-600'
138
+ return 'text-gray-500 dark:text-gray-400'
105
139
  }
106
140
 
107
141
  interface TemplateStats {
@@ -110,10 +144,16 @@ interface TemplateStats {
110
144
  links: number
111
145
  }
112
146
 
113
- const compatibilityIssues = ref<CompatibilityIssue[]>([])
147
+ const compatibilityIssues = ref<CheckIssue[]>([])
114
148
  const compatibilityLoading = ref(false)
115
149
  const compatibilityError = ref('')
116
150
  const compatibilityCategory = ref('')
151
+ // Injected by serveDevUI into index.html — synchronous, available before
152
+ // any HTTP calls, so the Checks tab never flashes in when disabled.
153
+ const checksConfig = (window as any).__MAIZZLE_CONFIG__?.checks
154
+ const compatibilityDisabled = ref(checksConfig === false)
155
+ const expandedIssueKeys = ref(new Set<string>())
156
+ const issueKey = (issue: CheckIssue, i: number): string => `${issue.file}|${issue.line ?? 0}|${issue.slug ?? issue.title}|${i}`
117
157
  const compatibilityCategories = ['css', 'html', 'image', 'others'] as const
118
158
  const activeCompatibilityCategories = computed(() =>
119
159
  compatibilityCategories.filter(cat => compatibilityIssues.value.some(i => i.category === cat))
@@ -122,8 +162,6 @@ const filteredCompatibilityIssues = computed(() => {
122
162
  if (!compatibilityCategory.value) return compatibilityIssues.value
123
163
  return compatibilityIssues.value.filter(i => i.category === compatibilityCategory.value)
124
164
  })
125
- const lintIssues = ref<LintIssue[]>([])
126
- const lintLoading = ref(false)
127
165
  const stats = ref<TemplateStats | null>(null)
128
166
  const statsLoading = ref(false)
129
167
 
@@ -196,6 +234,19 @@ function updateIframeContentHeight() {
196
234
  }
197
235
  }
198
236
 
237
+ function onIframeLoad() {
238
+ updateIframeContentHeight()
239
+ const iframe = iframeEl.value
240
+ if (darkMode.value && iframe) applyColorInversion(iframe)
241
+ }
242
+
243
+ watch(darkMode, (on) => {
244
+ const iframe = iframeEl.value
245
+ if (!iframe) return
246
+ if (on) applyColorInversion(iframe)
247
+ else undoColorInversion(iframe)
248
+ })
249
+
199
250
  async function fetchTemplate() {
200
251
  const res = await fetch(`/__maizzle/render/${route.params.template}`)
201
252
  renderedHtml = await res.text()
@@ -211,6 +262,7 @@ async function fetchTemplate() {
211
262
  doc.close()
212
263
  // Hide iframe body overflow — scrolling is handled by the outer ScrollArea
213
264
  if (doc.body) doc.body.style.overflow = 'hidden'
265
+ if (darkMode.value && iframe) applyColorInversion(iframe)
214
266
  await nextTick()
215
267
  updateIframeContentHeight()
216
268
  } else {
@@ -247,22 +299,30 @@ async function fetchStats() {
247
299
  }
248
300
 
249
301
  async function fetchCompatibility() {
302
+ if (compatibilityDisabled.value) return
303
+ const template = props.templates?.find(t => t.href === '/' + route.params.template)
304
+ if (!template) return
305
+
250
306
  compatibilityLoading.value = true
251
307
  compatibilityError.value = ''
252
308
  try {
253
- const res = await fetch('/__maizzle/compatibility', {
254
- method: 'POST',
255
- body: renderedHtml,
256
- })
309
+ const res = await fetch(`/__maizzle/compatibility/${template.path}`)
257
310
  const data = await res.json()
258
- if (data?.error) {
311
+ if (!Array.isArray(data) && data?.error) {
259
312
  compatibilityError.value = data.error
260
313
  compatibilityIssues.value = []
261
314
  } 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 || ''
315
+ const issues: CheckIssue[] = Array.isArray(data) ? data : []
316
+ compatibilityIssues.value = issues
317
+ // Keep the current category if it still has issues; otherwise fall
318
+ // back to the first category that does. Prevents a "refresh" during
319
+ // edits from snapping back to CSS when the user is on HTML/Image.
320
+ const current = compatibilityCategory.value
321
+ const currentStillActive = current && issues.some((i) => i.category === current)
322
+ if (!currentStillActive) {
323
+ const firstCat = compatibilityCategories.find(cat => issues.some((i) => i.category === cat))
324
+ compatibilityCategory.value = firstCat || ''
325
+ }
266
326
  }
267
327
  } catch {
268
328
  compatibilityIssues.value = []
@@ -271,19 +331,21 @@ async function fetchCompatibility() {
271
331
  }
272
332
  }
273
333
 
274
- async function fetchLint() {
275
- lintLoading.value = true
276
- try {
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) : []
282
- } catch {
283
- lintIssues.value = []
284
- } finally {
285
- lintLoading.value = false
286
- }
334
+ /** Check if an issue is from the currently viewed template file */
335
+ function isCurrentFile(issue: { file: string }): boolean {
336
+ const template = props.templates?.find(t => t.href === '/' + route.params.template)
337
+ if (!template) return true
338
+ return issue.file.endsWith(template.path)
339
+ }
340
+
341
+ /** Get a short display name for a component file path */
342
+ function componentName(filePath: string): string {
343
+ const parts = filePath.replace(/\\/g, '/').split('/')
344
+ return parts[parts.length - 1]?.replace(/\.vue$/, '') ?? filePath
345
+ }
346
+
347
+ function openInEditor(file: string, line: number) {
348
+ fetch(`/__open-in-editor?file=${encodeURIComponent(file + ':' + line)}`)
287
349
  }
288
350
 
289
351
  watch(() => route.params.template, () => {
@@ -292,17 +354,23 @@ watch(() => route.params.template, () => {
292
354
  plaintextContent.value = ''
293
355
  compatibilityIssues.value = []
294
356
  compatibilityError.value = ''
295
- lintIssues.value = []
296
357
  stats.value = null
297
358
  emailResult.value = null
298
359
  sourceView.value = 'compiled'
299
- fetchTemplate().then(fetchCompatibility)
300
- fetchLint()
360
+ fetchTemplate()
361
+ fetchCompatibility()
301
362
  fetchStats()
302
363
  fetchEmailConfig()
303
364
  if (viewMode.value === 'source') fetchSource()
304
365
  }, { immediate: true })
305
366
 
367
+ // Templates list loads async from App.vue — re-trigger once available
368
+ watch(() => props.templates, (templates) => {
369
+ if (templates?.length && !compatibilityIssues.value.length && !compatibilityLoading.value) {
370
+ fetchCompatibility()
371
+ }
372
+ })
373
+
306
374
  watch(viewMode, (mode) => {
307
375
  if (mode === 'source') {
308
376
  if (sourceView.value === 'compiled' && !sourceHtml.value) fetchSource()
@@ -319,8 +387,8 @@ watch(sourceView, (view) => {
319
387
 
320
388
  if ((import.meta as any).hot) {
321
389
  ;(import.meta as any).hot.on('maizzle:template-updated', () => {
322
- fetchTemplate().then(fetchCompatibility)
323
- fetchLint()
390
+ fetchTemplate()
391
+ fetchCompatibility()
324
392
  fetchStats()
325
393
 
326
394
  // Always clear all source views so they re-fetch when switched to
@@ -335,33 +403,22 @@ if ((import.meta as any).hot) {
335
403
  if (sourceView.value === 'plaintext') fetchPlaintext()
336
404
  }
337
405
  })
338
- }
339
-
340
-
341
- async function goToLine(line: number) {
342
- // Switch to source view showing Vue source
343
- viewMode.value = 'source'
344
- sourceView.value = 'vue'
345
-
346
- // Ensure vue source is loaded
347
- if (!vueSourceHtml.value) {
348
- await fetchVueSource()
349
- }
350
-
351
- await nextTick()
352
-
353
- const el = vueSourceEl.value
354
- if (!el) return
355
-
356
- // Remove previous highlight
357
- el.querySelectorAll('.shiki-highlight-line').forEach(l => l.classList.remove('shiki-highlight-line'))
358
406
 
359
- // Find and highlight the line
360
- const lineEl = el.querySelector(`[data-line="${line}"]`)
361
- if (lineEl) {
362
- lineEl.classList.add('shiki-highlight-line')
363
- lineEl.scrollIntoView({ block: 'center', behavior: 'smooth' })
364
- }
407
+ // Keep the UI in sync with live config edits. Payload is the same shape
408
+ // as the initial `window.__MAIZZLE_CONFIG__` inject — we replace it and
409
+ // derive per-feature flags from there.
410
+ ;(import.meta as any).hot.on('maizzle:config-updated', (data: Record<string, unknown>) => {
411
+ ;(window as any).__MAIZZLE_CONFIG__ = data
412
+ const wasDisabled = compatibilityDisabled.value
413
+ const nowDisabled = data?.checks === false
414
+ compatibilityDisabled.value = nowDisabled
415
+ if (nowDisabled) {
416
+ compatibilityIssues.value = []
417
+ if (activeTab.value === 'compatibility') activeTab.value = 'stats'
418
+ } else if (wasDisabled) {
419
+ fetchCompatibility()
420
+ }
421
+ })
365
422
  }
366
423
 
367
424
  async function goToCompiledLine(line: number) {
@@ -501,6 +558,7 @@ function forwardIframeKeys(iframe: HTMLIFrameElement) {
501
558
  metaKey: e.metaKey,
502
559
  shiftKey: e.shiftKey,
503
560
  altKey: e.altKey,
561
+ bubbles: true,
504
562
  }))
505
563
  })
506
564
  } catch {}
@@ -536,11 +594,13 @@ const bottomPanelOpen = ref(false)
536
594
  const tabsPanelHeight = ref(40)
537
595
  const activeTab = ref<string | undefined>(undefined)
538
596
 
597
+ const defaultTab = () => compatibilityDisabled.value ? 'stats' : 'compatibility'
598
+
539
599
  function toggleBottomPanel() {
540
600
  bottomPanelOpen.value = !bottomPanelOpen.value
541
601
  if (bottomPanelOpen.value) {
542
602
  tabsPanelHeight.value = 300
543
- if (!activeTab.value) activeTab.value = 'compatibility'
603
+ if (!activeTab.value) activeTab.value = defaultTab()
544
604
  } else {
545
605
  tabsPanelHeight.value = 40
546
606
  activeTab.value = undefined
@@ -563,35 +623,39 @@ function onTabClick(tab: string) {
563
623
 
564
624
  const tabsDragging = ref(false)
565
625
 
566
- function onTabsDragStart(e: MouseEvent) {
626
+ function onTabsDragStart(e: MouseEvent | TouchEvent) {
567
627
  e.preventDefault()
568
628
  tabsDragging.value = true
569
- const startY = e.clientY
629
+ const isTouch = e.type === 'touchstart'
630
+ const startY = isTouch ? (e as TouchEvent).touches[0].clientY : (e as MouseEvent).clientY
570
631
  const startHeight = tabsPanelHeight.value
571
632
 
572
633
  const rootEl = containerEl.value?.closest('.relative.h-full') as HTMLElement | null
573
634
  const maxHeight = rootEl ? rootEl.getBoundingClientRect().height : Infinity
574
635
 
575
- const onMouseMove = (e: MouseEvent) => {
576
- const newHeight = Math.max(40, Math.min(maxHeight, startHeight + startY - e.clientY))
636
+ const onMove = (e: MouseEvent | TouchEvent) => {
637
+ const clientY = e.type === 'touchmove' ? (e as TouchEvent).touches[0].clientY : (e as MouseEvent).clientY
638
+ const newHeight = Math.max(40, Math.min(maxHeight, startHeight + startY - clientY))
577
639
  tabsPanelHeight.value = newHeight
578
640
  bottomPanelOpen.value = newHeight > 40
579
641
 
580
642
  if (!bottomPanelOpen.value) {
581
643
  activeTab.value = undefined
582
644
  } else if (!activeTab.value) {
583
- activeTab.value = 'compatibility'
645
+ activeTab.value = defaultTab()
584
646
  }
585
647
  }
586
648
 
587
- const onMouseUp = () => {
649
+ const onEnd = () => {
588
650
  tabsDragging.value = false
589
- document.removeEventListener('mousemove', onMouseMove)
590
- document.removeEventListener('mouseup', onMouseUp)
651
+ document.removeEventListener('mousemove', onMove)
652
+ document.removeEventListener('mouseup', onEnd)
653
+ document.removeEventListener('touchmove', onMove)
654
+ document.removeEventListener('touchend', onEnd)
591
655
  }
592
656
 
593
- document.addEventListener('mousemove', onMouseMove)
594
- document.addEventListener('mouseup', onMouseUp)
657
+ document.addEventListener(isTouch ? 'touchmove' : 'mousemove', onMove)
658
+ document.addEventListener(isTouch ? 'touchend' : 'mouseup', onEnd)
595
659
  }
596
660
 
597
661
  const stripeBg = {
@@ -700,7 +764,7 @@ const stripeBg = {
700
764
  <iframe
701
765
  ref="iframeEl"
702
766
  :srcdoc="srcdoc"
703
- @load="updateIframeContentHeight"
767
+ @load="onIframeLoad"
704
768
  class="w-full border-0 bg-white dark:bg-gray-950"
705
769
  :style="{ height: iframeContentHeight ? `${iframeContentHeight}px` : '100%' }"
706
770
  />
@@ -722,20 +786,18 @@ const stripeBg = {
722
786
  <div
723
787
  class="relative h-0 cursor-row-resize before:absolute before:top-0 before:left-0 before:right-0 before:h-3.25 before:content-['']"
724
788
  @mousedown="onTabsDragStart"
789
+ @touchstart.prevent="onTabsDragStart"
725
790
  />
726
791
  <Tabs :model-value="activeTab" class="flex flex-col min-h-0 h-full">
727
792
  <div class="flex items-center justify-between min-h-10 pl-2 pr-3 shrink-0" :class="bottomPanelOpen ? 'border-b' : ''">
728
793
  <TabsList class="h-full bg-transparent! rounded-none! p-0 gap-1">
729
- <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')">
730
- Compatibility
731
- </TabsTrigger>
732
- <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')">
733
- Linter
794
+ <TabsTrigger v-if="!compatibilityDisabled" 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 dark:bg-transparent! dark:hover:bg-transparent!" @click="onTabClick('compatibility')">
795
+ Checks
734
796
  </TabsTrigger>
735
- <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')">
797
+ <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 dark:bg-transparent! dark:hover:bg-transparent!" @click="onTabClick('stats')">
736
798
  Stats
737
799
  </TabsTrigger>
738
- <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')">
800
+ <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 dark:bg-transparent! dark:hover:bg-transparent!" @click="onTabClick('test')">
739
801
  Test
740
802
  </TabsTrigger>
741
803
  </TabsList>
@@ -749,7 +811,7 @@ const stripeBg = {
749
811
  <button
750
812
  v-for="cat in activeCompatibilityCategories"
751
813
  :key="cat"
752
- class="px-2 py-0.5 text-[11px] rounded-full cursor-pointer transition-colors"
814
+ class="px-2 py-0.5 text-[11px] rounded-full cursor-default transition-colors"
753
815
  :class="compatibilityCategory === cat
754
816
  ? 'bg-gray-900 text-white dark:bg-gray-600 dark:text-gray-100'
755
817
  : 'text-gray-500 dark:text-gray-400 hover:bg-gray-100 dark:hover:bg-white/10'"
@@ -760,48 +822,45 @@ const stripeBg = {
760
822
  </button>
761
823
  </div>
762
824
  <ScrollArea class="h-full flex-1 min-h-0 pl-5">
763
- <p v-if="compatibilityLoading" class="pr-4 py-3 text-xs text-gray-500 dark:text-gray-400">Checking compatibility...</p>
825
+ <p v-if="compatibilityLoading" class="pr-4 py-3 text-xs text-gray-500 dark:text-gray-400">Running checks...</p>
764
826
  <p v-else-if="compatibilityError" class="pr-4 py-3 text-xs text-red-500 dark:text-red-400">{{ compatibilityError }}</p>
765
- <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>
827
+ <p v-else-if="compatibilityIssues.length === 0" class="pr-4 py-3 text-xs text-gray-500 dark:text-gray-400">No issues found.</p>
766
828
  <ul v-else class="text-xs divide-y">
767
829
  <li
768
830
  v-for="(issue, i) in filteredCompatibilityIssues"
769
831
  :key="i"
770
- class="pr-4 py-1.5 hover:bg-gray-50 dark:hover:bg-white/5"
832
+ class="pr-4 py-2"
771
833
  >
772
- <div class="flex items-center gap-2">
773
- <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'">
774
- {{ issue.title }}
775
- </a>
776
- <span v-else class="font-medium shrink-0" :class="issue.type === 'error' ? 'text-red-600' : 'text-amber-600'">
777
- {{ issue.title }}
778
- </span>
779
- <span class="text-gray-400 dark:text-gray-600 shrink-0">&middot;</span>
780
- <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>
781
- <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>
782
- </div>
783
- </li>
784
- </ul>
785
- </ScrollArea>
786
- </TabsContent>
787
- <TabsContent value="lint" class="mt-0 h-full">
788
- <ScrollArea class="h-full pl-5">
789
- <p v-if="lintLoading" class="pr-4 py-3 text-xs text-gray-500 dark:text-gray-400">Linting...</p>
790
- <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>
791
- <ul v-else class="text-xs divide-y">
792
- <li
793
- v-for="(issue, i) in lintIssues"
794
- :key="i"
795
- class="pr-4 py-2 hover:bg-gray-50 dark:hover:bg-white/5"
796
- >
797
- <div class="flex items-start justify-between gap-4">
834
+ <div class="flex items-center justify-between gap-4">
798
835
  <div>
799
- <span class="font-medium" :class="issue.type === 'error' ? 'text-red-600' : 'text-amber-600'">
836
+ <a v-if="issue.url" :href="issue.url" target="_blank" rel="noopener" class="font-medium hover:underline" :class="issueColorClass(issue)">
837
+ {{ issue.title }}
838
+ </a>
839
+ <span v-else class="font-medium" :class="issueColorClass(issue)">
800
840
  {{ issue.title }}
801
841
  </span>
802
- <div class="text-gray-500 dark:text-gray-400 mt-0.5">{{ issue.message }}</div>
842
+ <div class="text-gray-500 dark:text-gray-400 mt-0.5">
843
+ <template v-if="issue.kind === 'lint'">
844
+ <template v-for="(seg, j) in messageSegments(issue.message)" :key="j">
845
+ <code v-if="seg.code" class="px-1 py-0.5 rounded bg-gray-100 dark:bg-white/10 font-mono text-[11px]">{{ seg.text }}</code>
846
+ <template v-else>{{ seg.text }}</template>
847
+ </template>
848
+ </template>
849
+ <template v-else>
850
+ {{ supportPrefix(issue) }}
851
+ <template v-if="(issue.affectedClients?.length ?? 0) <= 4 || expandedIssueKeys.has(issueKey(issue, i))">
852
+ {{ (issue.affectedClients ?? []).join(', ') }}
853
+ </template>
854
+ <template v-else>
855
+ {{ issue.affectedClients!.slice(0, 4).join(', ') }}
856
+ <button class="underline cursor-pointer hover:text-gray-700 dark:hover:text-gray-200" @click="expandedIssueKeys.add(issueKey(issue, i)); expandedIssueKeys = new Set(expandedIssueKeys)">
857
+ + {{ issue.affectedClients!.length - 4 }} others
858
+ </button>
859
+ </template>
860
+ </template>
861
+ </div>
803
862
  </div>
804
- <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>
863
+ <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="openInEditor(issue.file, issue.line!)">{{ isCurrentFile(issue) ? `L${issue.line}` : `${componentName(issue.file)}:${issue.line}` }}</button>
805
864
  </div>
806
865
  </li>
807
866
  </ul>
@@ -811,35 +870,35 @@ const stripeBg = {
811
870
  <ScrollArea class="h-full pl-5">
812
871
  <p v-if="statsLoading" class="pr-4 py-3 text-xs text-gray-500 dark:text-gray-400">Loading stats...</p>
813
872
  <p v-else-if="!stats" class="pr-4 py-3 text-xs text-gray-500 dark:text-gray-400">No stats available.</p>
814
- <div v-else class="pr-4 py-3 flex items-center gap-6 text-xs">
815
- <div class="flex items-center gap-1.5">
816
- <span class="text-gray-500 dark:text-gray-400">Size</span>
817
- <span
818
- class="font-medium tabular-nums"
819
- :class="stats.size.bytes > 102400 ? 'text-red-600' : stats.size.bytes > 51200 ? 'text-amber-600' : 'text-gray-900 dark:text-gray-300'"
820
- >{{ stats.size.formatted }}</span>
821
- <TooltipProvider :delay-duration="0">
822
- <Tooltip>
823
- <TooltipTrigger as-child>
824
- <button type="button">
825
- <Info class="size-3 text-gray-400 dark:text-gray-500" />
826
- </button>
827
- </TooltipTrigger>
828
- <TooltipContent class="max-w-60">
829
- Compiled HTML size, excludes image files. Gmail clips content at ~100KB.
830
- </TooltipContent>
831
- </Tooltip>
832
- </TooltipProvider>
833
- </div>
834
- <div class="flex items-center gap-1.5">
835
- <span class="text-gray-500 dark:text-gray-400">Images</span>
836
- <span class="font-medium tabular-nums">{{ stats.images }}</span>
837
- </div>
838
- <div class="flex items-center gap-1.5">
839
- <span class="text-gray-500 dark:text-gray-400">Links</span>
840
- <span class="font-medium tabular-nums">{{ stats.links }}</span>
841
- </div>
842
- </div>
873
+ <ul v-else class="text-xs divide-y divide-gray-200 dark:divide-white/10">
874
+ <li class="pr-4 py-2">
875
+ <div class="flex items-center justify-between gap-4">
876
+ <div>
877
+ <span class="font-medium" :class="stats.size.bytes > 102400 ? 'text-red-600' : stats.size.bytes > 51200 ? 'text-amber-600' : 'text-gray-900 dark:text-gray-300'">Size</span>
878
+ <div class="text-gray-500 dark:text-gray-400 mt-0.5">Compiled HTML size. Gmail clips emails larger than ~100KB.</div>
879
+ </div>
880
+ <span class="font-medium tabular-nums shrink-0" :class="stats.size.bytes > 102400 ? 'text-red-600' : stats.size.bytes > 51200 ? 'text-amber-600' : 'text-gray-900 dark:text-gray-300'">{{ stats.size.formatted }}</span>
881
+ </div>
882
+ </li>
883
+ <li class="pr-4 py-2">
884
+ <div class="flex items-center justify-between gap-4">
885
+ <div>
886
+ <span class="font-medium text-gray-900 dark:text-gray-300">Images</span>
887
+ <div class="text-gray-500 dark:text-gray-400 mt-0.5">Total from &lt;img&gt; tags and CSS background images.</div>
888
+ </div>
889
+ <span class="font-medium tabular-nums shrink-0">{{ stats.images }}</span>
890
+ </div>
891
+ </li>
892
+ <li class="pr-4 py-2">
893
+ <div class="flex items-center justify-between gap-4">
894
+ <div>
895
+ <span class="font-medium text-gray-900 dark:text-gray-300">Links</span>
896
+ <div class="text-gray-500 dark:text-gray-400 mt-0.5">Total &lt;a&gt; tags with an href attribute.</div>
897
+ </div>
898
+ <span class="font-medium tabular-nums shrink-0">{{ stats.links }}</span>
899
+ </div>
900
+ </li>
901
+ </ul>
843
902
  </ScrollArea>
844
903
  </TabsContent>
845
904
  <TabsContent value="test" class="mt-0 h-full">
@@ -847,19 +906,19 @@ const stripeBg = {
847
906
  <div class="pr-4 py-3 max-w-md">
848
907
  <div class="space-y-2">
849
908
  <div class="flex items-center gap-2">
850
- <label class="text-xs text-gray-500 dark:text-gray-400 w-12 shrink-0">To</label>
909
+ <label for="email-to" class="text-xs text-gray-500 dark:text-gray-400 w-12 shrink-0 cursor-pointer">To</label>
851
910
  <TagsInput v-model="emailTo" delimiter=" " add-on-paste add-on-blur class="flex-1 min-h-7 gap-1 px-2 py-1">
852
911
  <TagsInputItem v-for="item in emailTo" :key="item" :value="item" class="h-5 text-xs rounded">
853
912
  <TagsInputItemText class="px-1.5 py-0 text-xs" />
854
913
  <TagsInputItemDelete class="size-3.5" />
855
914
  </TagsInputItem>
856
- <TagsInputInput class="text-xs min-h-5 px-0.5" placeholder="Add emails..." />
915
+ <TagsInputInput id="email-to" class="text-xs min-h-5 px-0.5" placeholder="Add emails..." />
857
916
  </TagsInput>
858
917
  </div>
859
918
  <div class="flex items-center gap-2">
860
- <label class="text-xs text-gray-500 dark:text-gray-400 w-12 shrink-0">Subject</label>
919
+ <label for="email-subject" class="text-xs text-gray-500 dark:text-gray-400 w-12 shrink-0 cursor-pointer">Subject</label>
861
920
  <div class="flex-1 flex items-center gap-3">
862
- <Input v-model="emailSubject" :placeholder="String(route.params.template)" class="flex-1 h-7 text-xs! px-2" />
921
+ <Input id="email-subject" v-model="emailSubject" :placeholder="String(route.params.template)" class="flex-1 h-7 text-xs! px-2" />
863
922
  <label class="flex items-center gap-1.5 cursor-pointer select-none shrink-0">
864
923
  <Checkbox v-model="emailPreventThreading" :default-checked="true" class="size-3.5" />
865
924
  <span class="text-xs text-gray-500 dark:text-gray-400">Prevent threading</span>
@@ -44,18 +44,22 @@ function addAttributes(dom, config = {}) {
44
44
  const attributesToAdd = defu(typeof addConfig === "object" ? addConfig : {}, DEFAULT_ATTRIBUTES);
45
45
  if (Object.keys(attributesToAdd).length === 0) return dom;
46
46
  for (const [selectorPattern, attributes] of Object.entries(attributesToAdd)) {
47
+ if (attributes === false) continue;
47
48
  const selectors = selectorPattern.split(",").map((s) => s.trim());
48
49
  walk(dom, (node) => {
49
50
  const el = node;
50
51
  if (!el.name) return;
51
52
  if (selectors.some((selector) => elementMatches(el, selector))) {
52
53
  if (!el.attribs) el.attribs = {};
53
- for (const [attrName, attrValue] of Object.entries(attributes)) if (attrName === "class" && el.attribs.class) {
54
- const existingClasses = el.attribs.class.split(/\s+/).filter(Boolean);
55
- const newClasses = String(attrValue).split(/\s+/).filter(Boolean);
56
- const mergedClasses = [...new Set([...existingClasses, ...newClasses])];
57
- if (mergedClasses.join(" ") !== el.attribs.class) el.attribs.class = mergedClasses.join(" ");
58
- } else if (!(attrName in el.attribs)) el.attribs[attrName] = String(attrValue);
54
+ for (const [attrName, attrValue] of Object.entries(attributes)) {
55
+ if (attrValue === false) continue;
56
+ if (attrName === "class" && el.attribs.class) {
57
+ const existingClasses = el.attribs.class.split(/\s+/).filter(Boolean);
58
+ const newClasses = String(attrValue).split(/\s+/).filter(Boolean);
59
+ const mergedClasses = [...new Set([...existingClasses, ...newClasses])];
60
+ if (mergedClasses.join(" ") !== el.attribs.class) el.attribs.class = mergedClasses.join(" ");
61
+ } else if (!(attrName in el.attribs)) el.attribs[attrName] = String(attrValue);
62
+ }
59
63
  }
60
64
  });
61
65
  }