nuxt-og-image 6.5.2 → 6.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (88) hide show
  1. package/dist/chunks/tw4.cjs +1 -1
  2. package/dist/chunks/tw4.mjs +1 -1
  3. package/dist/chunks/uno.cjs +1 -1
  4. package/dist/chunks/uno.mjs +1 -1
  5. package/dist/devtools/components/og-image/AddComponentDialog.vue +85 -0
  6. package/dist/devtools/components/og-image/BlueskyCardRenderer.vue +134 -0
  7. package/dist/devtools/components/og-image/CreateOgImageDialog.vue +56 -0
  8. package/dist/devtools/components/og-image/DiscordCardRenderer.vue +125 -0
  9. package/dist/devtools/components/og-image/FacebookCardRenderer.vue +128 -0
  10. package/dist/devtools/components/og-image/IFrameLoader.vue +93 -0
  11. package/dist/devtools/components/og-image/ImageLoader.vue +197 -0
  12. package/dist/devtools/components/og-image/LinkedInCardRenderer.vue +100 -0
  13. package/dist/devtools/components/og-image/RendererSelectModal.vue +356 -0
  14. package/dist/devtools/components/og-image/SlackCardRenderer.vue +140 -0
  15. package/dist/devtools/components/og-image/TemplateComponentPreview.vue +186 -0
  16. package/dist/devtools/components/og-image/TwitterCardRenderer.vue +170 -0
  17. package/dist/devtools/components/og-image/WhatsAppRenderer.vue +294 -0
  18. package/dist/devtools/lib/og-image/keys.ts +8 -0
  19. package/dist/devtools/lib/og-image/og-image.ts +536 -0
  20. package/dist/devtools/lib/og-image/renderer-select.ts +3 -0
  21. package/dist/devtools/lib/og-image/rpc-types.ts +2 -0
  22. package/dist/devtools/lib/og-image/rpc.ts +73 -0
  23. package/dist/devtools/lib/og-image/runtime-types.ts +10 -0
  24. package/dist/devtools/lib/og-image/shared/urlEncoding.ts +502 -0
  25. package/dist/devtools/lib/og-image/shared.ts +30 -0
  26. package/dist/devtools/lib/og-image/templates.ts +9 -0
  27. package/dist/devtools/lib/og-image/types.ts +38 -0
  28. package/dist/devtools/lib/og-image/util/logic.ts +21 -0
  29. package/dist/devtools/nuxt.config.ts +6 -0
  30. package/dist/devtools/pages/og-image/debug.vue +32 -0
  31. package/dist/devtools/pages/og-image/docs.vue +3 -0
  32. package/dist/devtools/pages/og-image/index.vue +1682 -0
  33. package/dist/devtools/pages/og-image/templates.vue +150 -0
  34. package/dist/devtools/pages/og-image.vue +184 -0
  35. package/dist/module.cjs +1 -1
  36. package/dist/module.json +1 -1
  37. package/dist/module.mjs +1 -1
  38. package/dist/runtime/server/og-image/core/plugins/imageSrc.js +2 -2
  39. package/dist/runtime/server/og-image/core/style-attr.d.ts +8 -0
  40. package/dist/runtime/server/og-image/core/style-attr.js +34 -0
  41. package/dist/runtime/server/og-image/core/vnodes.d.ts +2 -1
  42. package/dist/runtime/server/og-image/core/vnodes.js +3 -27
  43. package/dist/shared/{nuxt-og-image.CgPzmzQY.cjs → nuxt-og-image.CfTPCtaS.cjs} +38 -24
  44. package/dist/shared/{nuxt-og-image.DGAMxBol.mjs → nuxt-og-image.DdbTs-xp.mjs} +37 -23
  45. package/package.json +17 -18
  46. package/dist/devtools/200.html +0 -1
  47. package/dist/devtools/404.html +0 -1
  48. package/dist/devtools/_fonts/4ppnHhMi-pBsWSPo7mY0avYxlDoAg1N3PTzCwXLZ5rA-d9oibkGnTd1JL3tc_xnaVgBLYmOB8kjrK2cvZaqwj9s.woff2 +0 -0
  49. package/dist/devtools/_fonts/PV2hrQG6wq5BlIPDjdL1IcOflycaghyt5MHzlBqZtlo-lb_WexLz3VZqfTN0oi554iBH5tT2j2UFEV-XErCAS3E.woff2 +0 -0
  50. package/dist/devtools/_fonts/VE4cDVCv5MxbFM7ZLoLCGbIpNd71zhp7MDI9lmN5Y7I-xZyDYCUVrd6LV8eVGF3Um3UZjBFuUtDGtvdyTBBRYBo.woff2 +0 -0
  51. package/dist/devtools/_fonts/fVoGbnMbBFd5L9BBp9fUPavUSkZ_EmsQNSyadkT-108-U4T0khaeLQSIhtt9eVvaCEKJjtWJ4ioRJOf8hvqkWY0.woff2 +0 -0
  52. package/dist/devtools/_fonts/lQAxeCEs1R0Lw-H9XRU1RlOARQN8J6npRsPjyEDMe5s-_DUSLEkO3tKTuun_gSnDLoQPVEnpOnyqZMOw0ByZ6PA.woff2 +0 -0
  53. package/dist/devtools/_fonts/lntlqNHKLV2n82yTwMde70QqOjcfLE2XJ5oKZ3vRPWc-z6TxpIZQdWXztWLr9_OFWqt_WJJoeGtuK_-XQMZGQwE.woff2 +0 -0
  54. package/dist/devtools/_nuxt/B9jrmesR.js +0 -1
  55. package/dist/devtools/_nuxt/BA-4cUNc.js +0 -1
  56. package/dist/devtools/_nuxt/BOEXnX7x.js +0 -3
  57. package/dist/devtools/_nuxt/BWKJ0Uxb.js +0 -30
  58. package/dist/devtools/_nuxt/Bdtz4ZK9.js +0 -4
  59. package/dist/devtools/_nuxt/C5797Ieg.js +0 -1
  60. package/dist/devtools/_nuxt/C5jzcy9i.js +0 -1
  61. package/dist/devtools/_nuxt/CCTv7mmB.js +0 -1
  62. package/dist/devtools/_nuxt/CP0tQR2M.js +0 -1
  63. package/dist/devtools/_nuxt/C_JnDlx-.js +0 -1
  64. package/dist/devtools/_nuxt/CdmciVPJ.js +0 -1
  65. package/dist/devtools/_nuxt/CqjbkMN9.js +0 -3
  66. package/dist/devtools/_nuxt/CuJezOxb.js +0 -1
  67. package/dist/devtools/_nuxt/D4EkL0XJ.js +0 -6
  68. package/dist/devtools/_nuxt/DKaW7clv.js +0 -1
  69. package/dist/devtools/_nuxt/DSl3mlLY.js +0 -2
  70. package/dist/devtools/_nuxt/DYta7Mdi.js +0 -3
  71. package/dist/devtools/_nuxt/DevtoolsSection.CcOMr_vO.css +0 -1
  72. package/dist/devtools/_nuxt/DevtoolsSnippet.BhrTdbXn.css +0 -1
  73. package/dist/devtools/_nuxt/E8AZ6HoH.js +0 -1
  74. package/dist/devtools/_nuxt/IFrameLoader.k_861Nnq.css +0 -1
  75. package/dist/devtools/_nuxt/O5eLyffU.js +0 -1
  76. package/dist/devtools/_nuxt/builds/latest.json +0 -1
  77. package/dist/devtools/_nuxt/builds/meta/a36bc212-7179-4aa2-a534-6366229bd7b1.json +0 -1
  78. package/dist/devtools/_nuxt/entry.CcrXpo2y.css +0 -2
  79. package/dist/devtools/_nuxt/fira-code.Bc8wnsZt.woff2 +0 -0
  80. package/dist/devtools/_nuxt/hubot-sans.DLGyhQVu.woff2 +0 -0
  81. package/dist/devtools/_nuxt/pages.Ci_xvfJ8.css +0 -1
  82. package/dist/devtools/_nuxt/renderer-select.COGJ4ZQe.css +0 -1
  83. package/dist/devtools/_nuxt/templates.BByln3BG.css +0 -1
  84. package/dist/devtools/_nuxt/wMBdlVF-.js +0 -152
  85. package/dist/devtools/debug/index.html +0 -1
  86. package/dist/devtools/docs/index.html +0 -1
  87. package/dist/devtools/index.html +0 -1
  88. package/dist/devtools/templates/index.html +0 -1
@@ -0,0 +1,1682 @@
1
+ <script lang="ts" setup>
2
+ import { useHead } from 'nuxt/app'
3
+ import { hasProductionUrl, previewSource, productionUrl } from 'nuxtseo-layer-devtools/composables/state'
4
+ import { withHttps } from 'ufo'
5
+ import { computed, reactive, ref, watch } from 'vue'
6
+ import { useOgImage } from '../../lib/og-image/og-image'
7
+ import { RendererSelectDialogPromise } from '../../lib/og-image/renderer-select'
8
+ import { isConnectionFailed, isFallbackMode } from '../../lib/og-image/rpc'
9
+
10
+ const RE_SINGLE_QUOTE = /'/g
11
+ const RE_VUE_FILENAME = /[^/]+\.vue$/
12
+ const RE_HEX_COLOR = /^#(?:[0-9a-f]{3,4}|[0-9a-f]{6}|[0-9a-f]{8})$/i
13
+
14
+ const {
15
+ globalDebug,
16
+ isDebugLoading,
17
+ isCustomOgImage,
18
+ isValidDebugError,
19
+ hasDefinedOgImage,
20
+ fetchError,
21
+ aspectRatio,
22
+ imageFormat,
23
+ socialPreview,
24
+ imageColorMode,
25
+ src,
26
+ whatsappInlineSrc,
27
+ socialPreviewTitle,
28
+ socialPreviewDescription,
29
+ socialSiteUrl,
30
+ slackSocialPreviewSiteName,
31
+ activeComponentName,
32
+ activeComponent,
33
+ activeComponentRelativePath,
34
+ isOgImageTemplate,
35
+ renderer,
36
+ isComponentCompatibleWithRenderer,
37
+ sidePanelOpen,
38
+ isPageScreenshot,
39
+ currentPageFile,
40
+ allImageKeys,
41
+ description,
42
+ hasMadeChanges,
43
+ options,
44
+ propEditor,
45
+ ogImageKey,
46
+ toggleSocialPreview: _toggleSocialPreview,
47
+ generateLoadTime,
48
+ openImage,
49
+ openCurrentPageFile,
50
+ openCurrentComponent,
51
+ componentNames,
52
+ ejectComponent,
53
+ createComponent,
54
+ addExistingComponent,
55
+ resetProps,
56
+ updateProps,
57
+ refreshSources,
58
+ } = useOgImage()
59
+
60
+ const EXCLUDED_PROPS = new Set(['colorMode'])
61
+ const editableProps = computed(() =>
62
+ Object.keys(propEditor.value).filter(k => !EXCLUDED_PROPS.has(k)),
63
+ )
64
+
65
+ function editProp(key: string, value: unknown) {
66
+ propEditor.value = { ...propEditor.value, [key]: value }
67
+ updateProps({ [key]: value })
68
+ }
69
+
70
+ function inferPropType(value: unknown): 'color' | 'number' | 'json' | 'string' {
71
+ if (typeof value === 'number')
72
+ return 'number'
73
+ if (typeof value === 'string' && RE_HEX_COLOR.test(value))
74
+ return 'color'
75
+ if (typeof value === 'string')
76
+ return 'string'
77
+ return 'json'
78
+ }
79
+
80
+ const rendererIcons: Record<string, string> = {
81
+ satori: 'logos:vercel-icon',
82
+ browser: 'logos:chrome',
83
+ }
84
+
85
+ const activeFormatLabel = computed(() => {
86
+ const ext = imageFormat.value === 'jpeg' ? 'JPG' : imageFormat.value?.toUpperCase() || 'PNG'
87
+ const name = renderer.value === 'browser' ? 'Browser' : renderer.value === 'takumi' ? 'Takumi' : 'Satori'
88
+ return `${name} - ${ext}`
89
+ })
90
+
91
+ const socialItems = [
92
+ { label: 'Raw', icon: 'carbon:image', value: '' },
93
+ // Optical size adjustments: thin logos scale up, dense filled shapes scale down
94
+ { label: 'Twitter / X', icon: 'simple-icons:x', value: 'twitter', iconScale: 0.8 },
95
+ { label: 'Facebook', icon: 'simple-icons:facebook', value: 'facebook', iconScale: 0.92 },
96
+ { label: 'LinkedIn', icon: 'simple-icons:linkedin', value: 'linkedin', iconScale: 0.92 },
97
+ { label: 'Discord', icon: 'simple-icons:discord', value: 'discord', iconScale: 0.95 },
98
+ { label: 'Slack', icon: 'simple-icons:slack', value: 'slack' },
99
+ { label: 'WhatsApp', icon: 'simple-icons:whatsapp', value: 'whatsapp', iconScale: 0.92 },
100
+ { label: 'Bluesky', icon: 'simple-icons:bluesky', value: 'bluesky', iconScale: 0.92 },
101
+ ]
102
+
103
+ // Size variants per social platform — different display contexts show the card at different widths
104
+ interface SizeVariant { label: string, width: number }
105
+ const socialSizeVariants: Record<string, SizeVariant[]> = {
106
+ twitter: [
107
+ { label: 'Feed', width: 504 },
108
+ { label: 'Detail', width: 600 },
109
+ { label: 'Mobile', width: 335 },
110
+ ],
111
+ facebook: [
112
+ { label: 'Feed', width: 500 },
113
+ { label: 'Mobile', width: 320 },
114
+ ],
115
+ linkedin: [
116
+ { label: 'Feed', width: 552 },
117
+ { label: 'Mobile', width: 335 },
118
+ ],
119
+ discord: [
120
+ { label: 'Embed', width: 432 },
121
+ { label: 'Mobile', width: 300 },
122
+ ],
123
+ slack: [
124
+ { label: 'Desktop', width: 500 },
125
+ { label: 'Compact', width: 360 },
126
+ ],
127
+ whatsapp: [
128
+ { label: 'Default', width: 600 },
129
+ { label: 'Squared', width: 600 },
130
+ ],
131
+ bluesky: [
132
+ { label: 'Post', width: 513 },
133
+ { label: 'With link', width: 487 },
134
+ { label: 'Mobile', width: 335 },
135
+ ],
136
+ }
137
+
138
+ const activeSizeVariant = ref<Record<string, number>>({})
139
+
140
+ const currentSizeVariants = computed(() => socialSizeVariants[socialPreview.value] || [])
141
+ const currentSizeWidth = computed(() => {
142
+ const variants = currentSizeVariants.value
143
+ if (!variants.length)
144
+ return undefined
145
+ const idx = activeSizeVariant.value[socialPreview.value] ?? 0
146
+ return variants[idx]?.width
147
+ })
148
+
149
+ // Raw preview draggable resize
150
+ const rawResizeWidth = ref<number | null>(null)
151
+ const isRawDragging = ref(false)
152
+ const rawPreviewRef = ref<HTMLElement | null>(null)
153
+
154
+ const isRawMode = computed(() => !socialPreview.value)
155
+
156
+ const previewMaxWidth = computed(() => {
157
+ if (isRawMode.value && rawResizeWidth.value)
158
+ return `${rawResizeWidth.value}px`
159
+ if (currentSizeWidth.value)
160
+ return `${currentSizeWidth.value}px`
161
+ return undefined
162
+ })
163
+
164
+ const whatsappSquared = computed(() => {
165
+ const idx = activeSizeVariant.value.whatsapp ?? 0
166
+ return socialPreview.value === 'whatsapp' && idx === 1
167
+ })
168
+
169
+ function onRawHandlePointerDown(e: PointerEvent) {
170
+ e.preventDefault()
171
+ const el = rawPreviewRef.value
172
+ if (!el)
173
+ return
174
+ isRawDragging.value = true
175
+ const startX = e.clientX
176
+ const startWidth = el.getBoundingClientRect().width
177
+
178
+ function onMove(ev: PointerEvent) {
179
+ // Handle is centered, so distance from center = half the width change
180
+ const dx = ev.clientX - startX
181
+ rawResizeWidth.value = Math.max(200, Math.round(startWidth + dx * 2))
182
+ }
183
+ function onUp() {
184
+ isRawDragging.value = false
185
+ document.removeEventListener('pointermove', onMove)
186
+ document.removeEventListener('pointerup', onUp)
187
+ }
188
+ document.addEventListener('pointermove', onMove)
189
+ document.addEventListener('pointerup', onUp)
190
+ }
191
+
192
+ const protoTab = ref('meta-tags')
193
+ const protoTabs = computed(() => {
194
+ const tabs = [
195
+ { label: 'Meta Tags', value: 'meta-tags' },
196
+ { label: 'OG Image Props', value: 'og-props' },
197
+ { label: 'Fonts', value: 'fonts' },
198
+ ]
199
+ if (!socialPreview.value)
200
+ return tabs.filter(t => t.value !== 'meta-tags')
201
+ return tabs
202
+ })
203
+
204
+ watch(socialPreview, (val) => {
205
+ if (!val && protoTab.value === 'meta-tags')
206
+ protoTab.value = 'og-props'
207
+ })
208
+
209
+ const isTwitterMode = computed(() => socialPreview.value === 'twitter')
210
+ const metaLabelPrefix = computed(() => isTwitterMode.value ? 'Twitter' : 'OG')
211
+
212
+ interface FontFileEntry {
213
+ key: string
214
+ family: string
215
+ weight: number
216
+ style: string
217
+ src: string
218
+ label: string
219
+ loaded: boolean
220
+ subsetCount: number
221
+ subsets: string[]
222
+ }
223
+
224
+ const fontFiles = computed<FontFileEntry[]>(() => {
225
+ const available = globalDebug.value?.availableFonts || []
226
+ const resolved = globalDebug.value?.resolvedFonts || []
227
+ if (!available.length && !resolved.length)
228
+ return []
229
+ const source = available.length ? available : resolved
230
+ // Build set of resolved keys for loaded check
231
+ const resolvedKeys = new Set(resolved.map((f: any) => `${f.family}-${f.weight}-${f.style}`))
232
+ // Count subsets per family+weight+style and collect subset names
233
+ const subsetMap = new Map<string, string[]>()
234
+ for (const f of source) {
235
+ const key = `${f.family}-${f.weight}-${f.style}`
236
+ const subs = subsetMap.get(key) || []
237
+ if (f.subset)
238
+ subs.push(f.subset)
239
+ subsetMap.set(key, subs)
240
+ }
241
+ // Dedupe by family+weight+style (multiple unicode-range subsets)
242
+ const seen = new Set<string>()
243
+ const entries: FontFileEntry[] = []
244
+ for (const f of source) {
245
+ const key = `${f.family}-${f.weight}-${f.style}`
246
+ if (seen.has(key))
247
+ continue
248
+ seen.add(key)
249
+ const subs = subsetMap.get(key) || []
250
+ entries.push({
251
+ key,
252
+ family: f.family,
253
+ weight: f.weight,
254
+ style: f.style,
255
+ src: f.src,
256
+ label: `${f.family} ${f.weight}${f.style === 'italic' ? 'i' : ''}`,
257
+ loaded: resolvedKeys.has(key),
258
+ subsetCount: Math.max(subs.length, 1),
259
+ subsets: subs,
260
+ })
261
+ }
262
+ // Sort: by family, then weight, then style
263
+ entries.sort((a, b) => a.family.localeCompare(b.family) || a.weight - b.weight || a.style.localeCompare(b.style))
264
+ return entries
265
+ })
266
+
267
+ /** Total byte size of all resolved font files per family */
268
+ const fontFamilySizes = computed(() => {
269
+ const resolved = globalDebug.value?.resolvedFonts || []
270
+ const sizes = new Map<string, number>()
271
+ for (const f of resolved) {
272
+ if (f.size)
273
+ sizes.set(f.family, (sizes.get(f.family) || 0) + f.size)
274
+ }
275
+ return sizes
276
+ })
277
+
278
+ function formatBytes(bytes: number): string {
279
+ if (bytes < 1024)
280
+ return `${bytes} B`
281
+ const kb = bytes / 1024
282
+ return kb < 1000 ? `${kb.toFixed(1)} kB` : `${(kb / 1024).toFixed(1)} MB`
283
+ }
284
+
285
+ const fontFamilyNames = computed(() => [...new Set(fontFiles.value.map(f => f.family))])
286
+ const resolvedFamilyNames = computed(() => [...new Set(fontFiles.value.filter(f => f.loaded).map(f => f.family))])
287
+
288
+ const detectedFontRequirements = computed(() => globalDebug.value?.fontRequirements)
289
+
290
+ const unresolvedFamilies = computed(() => {
291
+ const detected = detectedFontRequirements.value?.families
292
+ if (!detected?.length)
293
+ return []
294
+ const available = new Set(fontFamilyNames.value)
295
+ return detected.filter((f: string) => !available.has(f))
296
+ })
297
+
298
+ const fontFaceCss = computed(() => {
299
+ const rules: string[] = []
300
+ const seen = new Set<string>()
301
+ for (const f of fontFiles.value) {
302
+ if (seen.has(f.family))
303
+ continue
304
+ seen.add(f.family)
305
+ rules.push(`@font-face { font-family: 'ogp-${f.family}'; src: url('${f.src}'); font-display: swap; }`)
306
+ }
307
+ return rules.join('\n')
308
+ })
309
+
310
+ useHead({ style: computed(() => fontFaceCss.value ? [fontFaceCss.value] : []) })
311
+
312
+ const fontOverride = ref('')
313
+
314
+ function applyFontOverride(family: string) {
315
+ const next = fontOverride.value === family ? '' : family
316
+ fontOverride.value = next
317
+ hasMadeChanges.value = true
318
+ updateProps({ fontFamily: next || undefined })
319
+ }
320
+
321
+ const metaOverrides = reactive({
322
+ ogTitle: '',
323
+ twitterTitle: '',
324
+ siteName: '',
325
+ description: '',
326
+ })
327
+
328
+ const effectiveTitle = computed(() => {
329
+ if (socialPreview.value === 'twitter' && metaOverrides.twitterTitle)
330
+ return metaOverrides.twitterTitle
331
+ return metaOverrides.ogTitle || socialPreviewTitle.value
332
+ })
333
+
334
+ const effectiveDescription = computed(() => {
335
+ return metaOverrides.description || socialPreviewDescription.value
336
+ })
337
+
338
+ const effectiveSiteName = computed(() => {
339
+ return metaOverrides.siteName || slackSocialPreviewSiteName.value
340
+ })
341
+
342
+ const effectiveSiteUrl = computed(() => {
343
+ return metaOverrides.siteName || socialSiteUrl.value
344
+ })
345
+
346
+ function updateMetaField(field: keyof typeof metaOverrides, value: string) {
347
+ metaOverrides[field] = value
348
+ hasMadeChanges.value = true
349
+ }
350
+
351
+ const hasMetaOverrides = computed(() => Object.values(metaOverrides).some(Boolean))
352
+
353
+ const seoMetaSnippet = computed(() => {
354
+ const entries: string[] = []
355
+ if (metaOverrides.ogTitle)
356
+ entries.push(` ogTitle: '${metaOverrides.ogTitle.replace(RE_SINGLE_QUOTE, '\\\'')}'`)
357
+ if (metaOverrides.description)
358
+ entries.push(` ogDescription: '${metaOverrides.description.replace(RE_SINGLE_QUOTE, '\\\'')}'`)
359
+ if (metaOverrides.siteName)
360
+ entries.push(` ogSiteName: '${metaOverrides.siteName.replace(RE_SINGLE_QUOTE, '\\\'')}'`)
361
+ if (metaOverrides.twitterTitle)
362
+ entries.push(` twitterTitle: '${metaOverrides.twitterTitle.replace(RE_SINGLE_QUOTE, '\\\'')}'`)
363
+ if (!entries.length)
364
+ return ''
365
+ return `useSeoMeta({\n${entries.join(',\n')}\n})`
366
+ })
367
+
368
+ function resetAll() {
369
+ resetProps(true)
370
+ fontOverride.value = ''
371
+ Object.assign(metaOverrides, { ogTitle: '', twitterTitle: '', siteName: '', description: '' })
372
+ }
373
+
374
+ const productionHostname = computed(() => {
375
+ try {
376
+ return new URL(productionUrl.value).hostname
377
+ }
378
+ catch {
379
+ return productionUrl.value
380
+ }
381
+ })
382
+ </script>
383
+
384
+ <template>
385
+ <div class="preview-container card animate-fade-up">
386
+ <!-- Demo mode when devtools connection fails -->
387
+ <div v-if="isConnectionFailed" class="h-full flex flex-col">
388
+ <DevtoolsAlert variant="warning">
389
+ Could not connect to devtools. Showing demo preview.
390
+ </DevtoolsAlert>
391
+ <div class="flex-1 flex items-center justify-center p-6 sm:p-8">
392
+ <div class="w-full max-w-2xl">
393
+ <TwitterCardRenderer title="My Page Title" :aspect-ratio="1200 / 630">
394
+ <template #domain>
395
+ <span>From example.com</span>
396
+ </template>
397
+ <div class="w-full h-full bg-gradient-to-br from-emerald-500 to-sky-500 flex items-center justify-center">
398
+ <div class="text-white text-center p-6">
399
+ <div class="text-2xl sm:text-4xl font-bold mb-2">
400
+ OG Image Preview
401
+ </div>
402
+ <div class="text-sm sm:text-lg opacity-80">
403
+ Connect devtools to see your actual image
404
+ </div>
405
+ </div>
406
+ </div>
407
+ </TwitterCardRenderer>
408
+ <p class="text-center text-sm text-[var(--color-text-muted)] mt-4">
409
+ Open this page in Nuxt DevTools to preview your OG images.
410
+ </p>
411
+ </div>
412
+ </div>
413
+ </div>
414
+
415
+ <!-- Custom OG Image (prebuilt URL) -->
416
+ <div v-else-if="isCustomOgImage" class="h-full flex flex-col">
417
+ <DevtoolsToolbar variant="minimal">
418
+ <span class="text-[var(--color-text-subtle)]">Prebuilt:</span>
419
+ <code class="text-[var(--color-text-muted)]">{{ options?.url }}</code>
420
+ </DevtoolsToolbar>
421
+ <div class="flex-1 flex items-center justify-center p-6 sm:p-8">
422
+ <ImageLoader
423
+ :src="src"
424
+ :aspect-ratio="aspectRatio"
425
+ @load="generateLoadTime"
426
+ @refresh="refreshSources"
427
+ />
428
+ </div>
429
+ </div>
430
+
431
+ <!-- Loading state -->
432
+ <div v-else-if="isDebugLoading" class="h-full flex items-center justify-center p-6 sm:p-8">
433
+ <DevtoolsEmptyState icon="carbon:image-search" title="Loading OG Image&#8230;" class="animate-pulse" />
434
+ </div>
435
+
436
+ <!-- Server error (e.g. font loading failure) -->
437
+ <div v-else-if="fetchError" class="h-full flex items-center justify-center p-6 sm:p-8">
438
+ <DevtoolsEmptyState
439
+ icon="carbon:warning"
440
+ title="OG Image Error"
441
+ :description="fetchError.message"
442
+ variant="error"
443
+ class="animate-scale-in"
444
+ >
445
+ <details v-if="fetchError.stack?.length" class="text-left w-full">
446
+ <summary class="text-xs text-[var(--color-text-subtle)] cursor-pointer hover:text-[var(--color-text-muted)]">
447
+ Stack trace
448
+ </summary>
449
+ <pre class="mt-2 text-xs text-[var(--color-text-subtle)] overflow-auto max-h-48 p-3 rounded-lg bg-[var(--color-surface-sunken)] border border-[var(--color-border-subtle)]">{{ fetchError.stack.join('\n') }}</pre>
450
+ </details>
451
+ </DevtoolsEmptyState>
452
+ </div>
453
+
454
+ <!-- Missing defineOgImage error -->
455
+ <div v-else-if="isValidDebugError || !hasDefinedOgImage" class="h-full flex items-center justify-center p-6 sm:p-8">
456
+ <DevtoolsEmptyState
457
+ icon="carbon:image-search"
458
+ title="No OG Image Defined"
459
+ class="animate-scale-in"
460
+ >
461
+ <template #description>
462
+ This page has no <code class="inline-code">defineOgImage()</code> in
463
+ <UButton variant="link" class="cursor-pointer" @click="openCurrentPageFile">
464
+ {{ currentPageFile }}
465
+ </UButton>
466
+ </template>
467
+
468
+ <!-- Existing components: let user pick one -->
469
+ <div v-if="componentNames.length" class="mb-4 flex flex-col items-center gap-3">
470
+ <div class="flex flex-wrap justify-center gap-2">
471
+ <UButton
472
+ v-for="c in componentNames"
473
+ :key="c.pascalName"
474
+ color="primary"
475
+ variant="soft"
476
+ size="sm"
477
+ @click="addExistingComponent(c.pascalName)"
478
+ >
479
+ {{ c.pascalName }}
480
+ </UButton>
481
+ </div>
482
+ <span class="text-xs text-[var(--color-text-subtle)]">or</span>
483
+ <UButton variant="link" size="xs" class="text-[var(--color-text-subtle)]" @click="createComponent()">
484
+ Create new component
485
+ </UButton>
486
+ </div>
487
+
488
+ <!-- No components: create is the primary CTA -->
489
+ <div v-else class="mb-4">
490
+ <UButton color="primary" size="md" @click="createComponent()">
491
+ <UIcon name="carbon:add" class="w-4 h-4" />
492
+ Create OG Image Component
493
+ </UButton>
494
+ </div>
495
+
496
+ <div v-if="globalDebug?.runtimeConfig?.hasNuxtContent" class="text-sm text-[var(--color-text-subtle)]">
497
+ Using Nuxt Content? See the <a href="https://nuxtseo.com/docs/integrations/content" target="_blank" class="text-[var(--seo-green)] hover:underline">integration guide</a>
498
+ </div>
499
+ <a v-else href="https://nuxtseo.com/og-image/getting-started/getting-familar-with-nuxt-og-image" target="_blank" class="text-sm text-[var(--seo-green)] hover:underline inline-flex items-center gap-1">
500
+ Learn more
501
+ <UIcon name="carbon:arrow-right" class="w-3 h-3" />
502
+ </a>
503
+ </DevtoolsEmptyState>
504
+ </div>
505
+
506
+ <!-- Main preview UI -->
507
+ <div v-else class="h-full flex flex-col">
508
+ <!-- Fallback mode banner -->
509
+ <DevtoolsAlert v-if="isFallbackMode" variant="info">
510
+ Fallback mode: Connected to localhost:3000
511
+ </DevtoolsAlert>
512
+
513
+ <!-- Production preview banner -->
514
+ <DevtoolsAlert v-if="previewSource === 'production' && hasProductionUrl" variant="production">
515
+ Previewing from <strong>{{ productionUrl }}</strong>
516
+ <template #action>
517
+ <UButton variant="link" size="xs" @click="previewSource = 'local'">
518
+ Switch to local
519
+ </UButton>
520
+ </template>
521
+ </DevtoolsAlert>
522
+
523
+ <!-- Renderer incompatibility banner -->
524
+ <DevtoolsAlert v-if="!isComponentCompatibleWithRenderer && activeComponent" variant="warning">
525
+ Component <code class="inline-code">{{ activeComponentName }}</code> uses {{ activeComponent.renderer }} renderer.
526
+ <NuxtLink to="/templates" class="text-[var(--seo-green)] hover:underline ml-1">
527
+ Select a {{ renderer }} template
528
+ </NuxtLink>
529
+ </DevtoolsAlert>
530
+
531
+ <!-- Top toolbar -->
532
+ <DevtoolsToolbar>
533
+ <!-- Left: Renderer + Format controls -->
534
+ <div class="flex items-center gap-2 sm:gap-3 flex-wrap">
535
+ <UButton color="neutral" variant="ghost" size="xs" aria-label="Change renderer and format" @click="RendererSelectDialogPromise.start()">
536
+ <img v-if="renderer === 'takumi'" src="https://takumi.kane.tw/logo.svg" class="w-3.5 h-3.5" width="14" height="14" alt="">
537
+ <UIcon v-else :name="rendererIcons[renderer] || 'logos:vercel-icon'" class="w-3.5 h-3.5" />
538
+ <span class="hidden sm:inline">{{ activeFormatLabel }}</span>
539
+ <UIcon name="carbon:chevron-down" class="w-3 h-3 opacity-60" />
540
+ </UButton>
541
+ </div>
542
+
543
+ <!-- Center: Component info -->
544
+ <div v-if="!isPageScreenshot" class="component-info hidden md:flex">
545
+ <UButton
546
+ variant="link"
547
+ class="component-path-link cursor-pointer"
548
+ :disabled="isOgImageTemplate"
549
+ @click="openCurrentComponent"
550
+ >
551
+ <span v-if="activeComponentRelativePath"><span class="text-[var(--color-text-muted)] hidden min-[850px]:inline">{{ activeComponentRelativePath.replace(RE_VUE_FILENAME, '') }}</span><span class="text-[var(--color-text)]">{{ activeComponentRelativePath.match(RE_VUE_FILENAME)?.[0] || activeComponentName }}</span></span>
552
+ <span v-else class="text-[var(--color-text)]">{{ activeComponentName }}</span>
553
+ </UButton>
554
+ <UButton
555
+ v-if="isOgImageTemplate"
556
+ variant="link"
557
+ size="xs"
558
+ class="cursor-pointer text-[var(--color-text-subtle)]"
559
+ @click="ejectComponent(activeComponentName)"
560
+ >
561
+ Eject
562
+ </UButton>
563
+ </div>
564
+ <div v-else class="component-info hidden md:flex">
565
+ <span class="text-[var(--color-text-subtle)]">Page Screenshot</span>
566
+ </div>
567
+
568
+ <!-- Right: Props toggle -->
569
+ <div class="flex items-center gap-2">
570
+ <!-- Production URL indicator -->
571
+ <UTooltip v-if="previewSource === 'production' && hasProductionUrl" :text="productionUrl" :delay-duration="200">
572
+ <span class="devtools-production-badge">
573
+ <span class="devtools-production-dot" />
574
+ <span class="hidden sm:inline text-xs">{{ productionHostname }}</span>
575
+ </span>
576
+ </UTooltip>
577
+
578
+ <UButton
579
+ v-if="!isPageScreenshot"
580
+ :variant="sidePanelOpen ? 'soft' : 'ghost'"
581
+ :color="sidePanelOpen ? 'primary' : 'neutral'"
582
+ size="xs"
583
+ icon="carbon:settings-adjust"
584
+ class="props-toggle"
585
+ @click="sidePanelOpen = !sidePanelOpen"
586
+ >
587
+ <span class="hidden sm:inline">Debug</span>
588
+ </UButton>
589
+ </div>
590
+ </DevtoolsToolbar>
591
+
592
+ <!-- Social preview tabs -->
593
+ <div class="px-3 sm:px-4 border-b border-[var(--color-border)] bg-[var(--color-surface-elevated)]">
594
+ <UTabs
595
+ v-model="socialPreview"
596
+ :items="socialItems"
597
+ :content="false"
598
+ size="xs"
599
+ variant="link"
600
+ color="neutral"
601
+ >
602
+ <template #leading="{ item, ui }">
603
+ <UIcon
604
+ :name="item.icon"
605
+ :class="ui.leadingIcon"
606
+ :style="item.iconScale ? { transform: `scale(${item.iconScale})` } : undefined"
607
+ />
608
+ </template>
609
+ </UTabs>
610
+ </div>
611
+
612
+ <!-- Size variant toggles + image key selector -->
613
+ <div v-if="currentSizeVariants.length || allImageKeys.length > 1" class="size-variant-bar">
614
+ <template v-if="currentSizeVariants.length">
615
+ <span class="size-variant-label">Width</span>
616
+ <div class="size-variant-group">
617
+ <button
618
+ v-for="(variant, idx) in currentSizeVariants"
619
+ :key="variant.label"
620
+ class="size-variant-btn"
621
+ :class="{ active: (activeSizeVariant[socialPreview] ?? 0) === idx }"
622
+ @click="activeSizeVariant[socialPreview] = idx"
623
+ >
624
+ <span class="size-variant-name">{{ variant.label }}</span>
625
+ <span class="size-variant-px">{{ variant.width }}px</span>
626
+ </button>
627
+ </div>
628
+ </template>
629
+
630
+ <template v-if="allImageKeys.length > 1">
631
+ <span class="size-variant-label">Image</span>
632
+ <div class="size-variant-group">
633
+ <button
634
+ v-for="key in allImageKeys"
635
+ :key="key"
636
+ class="size-variant-btn"
637
+ :class="{ active: (ogImageKey || 'og') === key }"
638
+ @click="ogImageKey = key"
639
+ >
640
+ <span class="size-variant-name">{{ key }}</span>
641
+ </button>
642
+ </div>
643
+ </template>
644
+ </div>
645
+
646
+ <!-- Preview area -->
647
+ <div class="preview-area panel-grids" :class="{ 'preview-area--panel-open': sidePanelOpen && !isPageScreenshot }">
648
+ <div ref="rawPreviewRef" class="preview-content" :class="{ 'preview-content--no-transition': isRawDragging }" :style="previewMaxWidth ? { maxWidth: previewMaxWidth } : undefined">
649
+ <!-- Twitter/X preview -->
650
+ <TwitterCardRenderer v-if="socialPreview === 'twitter'" :title="effectiveTitle" :aspect-ratio="aspectRatio">
651
+ <template #domain>
652
+ <a target="_blank" :href="withHttps(socialSiteUrl)">From {{ effectiveSiteUrl }}</a>
653
+ </template>
654
+ <ImageLoader
655
+ v-if="imageFormat !== 'html'"
656
+ :src="src"
657
+ :aspect-ratio="aspectRatio"
658
+ @load="generateLoadTime"
659
+ @click="openImage"
660
+ @refresh="refreshSources"
661
+ />
662
+ <IFrameLoader
663
+ v-else
664
+ :src="src"
665
+ :aspect-ratio="aspectRatio"
666
+ @load="generateLoadTime"
667
+ @refresh="refreshSources"
668
+ />
669
+ </TwitterCardRenderer>
670
+
671
+ <!-- Facebook preview -->
672
+ <FacebookCardRenderer v-else-if="socialPreview === 'facebook'">
673
+ <template #siteName>
674
+ {{ effectiveSiteUrl }}
675
+ </template>
676
+ <template #title>
677
+ {{ effectiveTitle }}
678
+ </template>
679
+ <template #description>
680
+ {{ effectiveDescription }}
681
+ </template>
682
+ <ImageLoader
683
+ v-if="imageFormat !== 'html'"
684
+ :src="src"
685
+ :aspect-ratio="aspectRatio"
686
+ @load="generateLoadTime"
687
+ @refresh="refreshSources"
688
+ />
689
+ <IFrameLoader
690
+ v-else
691
+ :src="src"
692
+ :aspect-ratio="aspectRatio"
693
+ @load="generateLoadTime"
694
+ @refresh="refreshSources"
695
+ />
696
+ </FacebookCardRenderer>
697
+
698
+ <!-- LinkedIn preview -->
699
+ <LinkedInCardRenderer v-else-if="socialPreview === 'linkedin'">
700
+ <template #siteName>
701
+ {{ effectiveSiteUrl }}
702
+ </template>
703
+ <template #title>
704
+ {{ effectiveTitle }}
705
+ </template>
706
+ <ImageLoader
707
+ v-if="imageFormat !== 'html'"
708
+ :src="src"
709
+ :aspect-ratio="aspectRatio"
710
+ @load="generateLoadTime"
711
+ @refresh="refreshSources"
712
+ />
713
+ <IFrameLoader
714
+ v-else
715
+ :src="src"
716
+ :aspect-ratio="aspectRatio"
717
+ @load="generateLoadTime"
718
+ @refresh="refreshSources"
719
+ />
720
+ </LinkedInCardRenderer>
721
+
722
+ <!-- Discord preview -->
723
+ <DiscordCardRenderer v-else-if="socialPreview === 'discord'">
724
+ <template #siteName>
725
+ {{ effectiveSiteName }}
726
+ </template>
727
+ <template #title>
728
+ {{ effectiveTitle }}
729
+ </template>
730
+ <template #description>
731
+ {{ effectiveDescription }}
732
+ </template>
733
+ <ImageLoader
734
+ v-if="imageFormat !== 'html'"
735
+ :src="src"
736
+ :aspect-ratio="aspectRatio"
737
+ @load="generateLoadTime"
738
+ @refresh="refreshSources"
739
+ />
740
+ <IFrameLoader
741
+ v-else
742
+ :src="src"
743
+ :aspect-ratio="aspectRatio"
744
+ @load="generateLoadTime"
745
+ @refresh="refreshSources"
746
+ />
747
+ </DiscordCardRenderer>
748
+
749
+ <!-- Slack preview -->
750
+ <SlackCardRenderer v-else-if="socialPreview === 'slack'">
751
+ <template #favIcon>
752
+ <img :src="`https://www.google.com/s2/favicons?domain=${encodeURIComponent(socialSiteUrl)}&sz=30`" width="30" height="30" alt="">
753
+ </template>
754
+ <template #siteName>
755
+ {{ effectiveSiteName }}
756
+ </template>
757
+ <template #title>
758
+ {{ effectiveTitle }}
759
+ </template>
760
+ <template #description>
761
+ {{ effectiveDescription }}
762
+ </template>
763
+ <ImageLoader
764
+ v-if="imageFormat !== 'html'"
765
+ :src="src"
766
+ :aspect-ratio="aspectRatio"
767
+ @load="generateLoadTime"
768
+ @refresh="refreshSources"
769
+ />
770
+ <IFrameLoader
771
+ v-else
772
+ :src="src"
773
+ :aspect-ratio="aspectRatio"
774
+ @load="generateLoadTime"
775
+ @refresh="refreshSources"
776
+ />
777
+ </SlackCardRenderer>
778
+
779
+ <!-- WhatsApp preview -->
780
+ <WhatsAppRenderer v-else-if="socialPreview === 'whatsapp'" :squared="whatsappSquared">
781
+ <template #siteName>
782
+ {{ effectiveSiteName }}
783
+ </template>
784
+ <template #title>
785
+ {{ effectiveTitle }}
786
+ </template>
787
+ <template #description>
788
+ {{ effectiveDescription }}
789
+ </template>
790
+ <template #url>
791
+ {{ effectiveSiteUrl }}
792
+ </template>
793
+ <template v-if="whatsappInlineSrc !== src" #inlineImage>
794
+ <img
795
+ :src="whatsappInlineSrc"
796
+ alt=""
797
+ >
798
+ </template>
799
+ <img
800
+ v-if="imageFormat !== 'html'"
801
+ :src="src"
802
+ alt=""
803
+ @load="generateLoadTime({ timeTaken: '0', sizeKb: '' })"
804
+ >
805
+ </WhatsAppRenderer>
806
+
807
+ <!-- Bluesky preview -->
808
+ <BlueskyCardRenderer v-else-if="socialPreview === 'bluesky'">
809
+ <template #siteName>
810
+ {{ effectiveSiteUrl }}
811
+ </template>
812
+ <template #title>
813
+ {{ effectiveTitle }}
814
+ </template>
815
+ <template #description>
816
+ {{ effectiveDescription }}
817
+ </template>
818
+ <ImageLoader
819
+ v-if="imageFormat !== 'html'"
820
+ :src="src"
821
+ :aspect-ratio="aspectRatio"
822
+ @load="generateLoadTime"
823
+ @refresh="refreshSources"
824
+ />
825
+ <IFrameLoader
826
+ v-else
827
+ :src="src"
828
+ :aspect-ratio="aspectRatio"
829
+ @load="generateLoadTime"
830
+ @refresh="refreshSources"
831
+ />
832
+ </BlueskyCardRenderer>
833
+
834
+ <!-- Raw preview with resize handle -->
835
+ <div v-else class="raw-preview-wrapper">
836
+ <div class="raw-preview" :class="{ 'raw-preview--dragging': isRawDragging }">
837
+ <ImageLoader
838
+ v-if="imageFormat !== 'html'"
839
+ :src="src"
840
+ :aspect-ratio="aspectRatio"
841
+ @load="generateLoadTime"
842
+ @refresh="refreshSources"
843
+ />
844
+ <IFrameLoader
845
+ v-else
846
+ :src="src"
847
+ :aspect-ratio="aspectRatio"
848
+ @load="generateLoadTime"
849
+ @refresh="refreshSources"
850
+ />
851
+ </div>
852
+ <!-- Drag handle — double-click resets -->
853
+ <div
854
+ class="raw-resize-handle"
855
+ @pointerdown="onRawHandlePointerDown"
856
+ @dblclick="rawResizeWidth = null"
857
+ >
858
+ <div class="raw-resize-grip" />
859
+ </div>
860
+ <!-- Width readout -->
861
+ <div v-if="rawResizeWidth" class="raw-resize-readout">
862
+ {{ rawResizeWidth }}px
863
+ </div>
864
+ </div>
865
+
866
+ <!-- Status line -->
867
+ <div v-if="description" class="status-line">
868
+ {{ description }}
869
+ </div>
870
+ </div>
871
+ </div>
872
+
873
+ <!-- Floating Props Panel -->
874
+ <Transition
875
+ enter-active-class="transition duration-200 ease-out"
876
+ enter-from-class="opacity-0 translate-x-4"
877
+ enter-to-class="opacity-100 translate-x-0"
878
+ leave-active-class="transition duration-150 ease-in"
879
+ leave-from-class="opacity-100 translate-x-0"
880
+ leave-to-class="opacity-0 translate-x-4"
881
+ >
882
+ <DevtoolsPanel v-if="sidePanelOpen && !isPageScreenshot" title="Debug" class="props-panel" @close="sidePanelOpen = false">
883
+ <template #actions>
884
+ <UButton
885
+ v-if="hasMadeChanges"
886
+ @click="resetAll()"
887
+ >
888
+ Reset
889
+ </UButton>
890
+ </template>
891
+ <div class="px-3 pt-2 border-b border-[var(--color-border)]">
892
+ <UTabs
893
+ v-model="protoTab"
894
+ :items="protoTabs"
895
+ :content="false"
896
+ size="xs"
897
+ variant="link"
898
+ color="neutral"
899
+ />
900
+ </div>
901
+
902
+ <!-- Meta Tags Tab -->
903
+ <div v-if="protoTab === 'meta-tags'">
904
+ <div class="props-field">
905
+ <div class="props-field-label">
906
+ <span>{{ metaLabelPrefix }} Title</span>
907
+ <UTooltip :text="isTwitterMode ? 'The twitter:title meta tag. Controls the title shown on Twitter/X cards.' : 'The og:title meta tag. Controls the title shown when shared on social platforms.'" :delay-duration="0">
908
+ <UIcon name="carbon:help" class="w-3.5 h-3.5 text-[var(--color-text-subtle)] cursor-help" />
909
+ </UTooltip>
910
+ </div>
911
+ <UInput
912
+ :model-value="isTwitterMode ? (metaOverrides.twitterTitle || socialPreviewTitle) : (metaOverrides.ogTitle || socialPreviewTitle)"
913
+ size="xs"
914
+ :name="isTwitterMode ? 'twitter-title' : 'og-title'"
915
+ autocomplete="off"
916
+ :placeholder="isTwitterMode ? 'twitter:title…' : 'og:title…'"
917
+ @update:model-value="updateMetaField(isTwitterMode ? 'twitterTitle' : 'ogTitle', $event as string)"
918
+ />
919
+ </div>
920
+
921
+ <div class="props-field">
922
+ <div class="props-field-label">
923
+ <span>{{ metaLabelPrefix }} Description</span>
924
+ <UTooltip :text="isTwitterMode ? 'The twitter:description meta tag. Controls the description shown on Twitter/X cards.' : 'The og:description meta tag. Controls the description shown when shared on social platforms.'" :delay-duration="0">
925
+ <UIcon name="carbon:help" class="w-3.5 h-3.5 text-[var(--color-text-subtle)] cursor-help" />
926
+ </UTooltip>
927
+ </div>
928
+ <UInput
929
+ :model-value="metaOverrides.description || socialPreviewDescription"
930
+ size="xs"
931
+ :name="isTwitterMode ? 'twitter-description' : 'og-description'"
932
+ autocomplete="off"
933
+ :placeholder="isTwitterMode ? 'twitter:description…' : 'og:description…'"
934
+ @update:model-value="updateMetaField('description', $event as string)"
935
+ />
936
+ </div>
937
+
938
+ <div class="props-field">
939
+ <div class="props-field-label">
940
+ <span>{{ socialPreview === 'discord' ? 'OG Site Name' : 'OG URL' }}</span>
941
+ <UTooltip :text="socialPreview === 'discord' ? 'The og:site_name meta tag. Discord uses this for the provider name above the title.' : 'The og:url meta tag. The canonical URL shown in social card previews.'" :delay-duration="0">
942
+ <UIcon name="carbon:help" class="w-3.5 h-3.5 text-[var(--color-text-subtle)] cursor-help" />
943
+ </UTooltip>
944
+ </div>
945
+ <UInput
946
+ :model-value="metaOverrides.siteName || slackSocialPreviewSiteName"
947
+ size="xs"
948
+ :name="socialPreview === 'discord' ? 'og-site-name' : 'og-url'"
949
+ autocomplete="off"
950
+ :placeholder="socialPreview === 'discord' ? 'og:site_name…' : 'og:url…'"
951
+ @update:model-value="updateMetaField('siteName', $event as string)"
952
+ />
953
+ </div>
954
+ </div>
955
+
956
+ <!-- OG Image Props Tab -->
957
+ <div v-else-if="protoTab === 'og-props'">
958
+ <div class="props-field">
959
+ <div class="props-field-label">
960
+ <span>Color Mode</span>
961
+ <UTooltip text="Changes the color mode passed to the OG image renderer." :delay-duration="0">
962
+ <UIcon name="carbon:help" class="w-3.5 h-3.5 text-[var(--color-text-subtle)] cursor-help" />
963
+ </UTooltip>
964
+ </div>
965
+ <UButton
966
+ size="xs"
967
+ color="neutral"
968
+ variant="soft"
969
+ :icon="imageColorMode === 'dark' ? 'carbon:moon' : 'carbon:sun'"
970
+ @click="imageColorMode = imageColorMode === 'dark' ? 'light' : 'dark'"
971
+ >
972
+ {{ imageColorMode === 'dark' ? 'Dark' : 'Light' }}
973
+ </UButton>
974
+ </div>
975
+
976
+ <div v-if="editableProps.length" class="prop-editor">
977
+ <div v-for="key in editableProps" :key="key" class="prop-field">
978
+ <label class="prop-label">{{ key }}</label>
979
+ <input
980
+ v-if="inferPropType(propEditor[key]) === 'color'"
981
+ type="color"
982
+ :value="propEditor[key]"
983
+ class="prop-input prop-input-color"
984
+ @input="editProp(key, ($event.target as HTMLInputElement).value)"
985
+ >
986
+ <input
987
+ v-else-if="inferPropType(propEditor[key]) === 'number'"
988
+ type="number"
989
+ :value="propEditor[key]"
990
+ class="prop-input"
991
+ @input="editProp(key, Number(($event.target as HTMLInputElement).value))"
992
+ >
993
+ <textarea
994
+ v-else-if="inferPropType(propEditor[key]) === 'json'"
995
+ :value="JSON.stringify(propEditor[key], null, 2)"
996
+ class="prop-input prop-input-json"
997
+ rows="3"
998
+ @change="(() => {
999
+ try { editProp(key, JSON.parse(($event.target as HTMLTextAreaElement).value)) }
1000
+ catch {
1001
+ // Invalid JSON is expected while editing; keep the previous prop value.
1002
+ }
1003
+ })()"
1004
+ />
1005
+ <input
1006
+ v-else
1007
+ type="text"
1008
+ :value="propEditor[key]"
1009
+ class="prop-input"
1010
+ @input="editProp(key, ($event.target as HTMLInputElement).value)"
1011
+ >
1012
+ </div>
1013
+ </div>
1014
+ <div v-else class="prop-editor-empty">
1015
+ No props defined
1016
+ </div>
1017
+ </div>
1018
+
1019
+ <!-- Fonts Tab -->
1020
+ <div v-else-if="protoTab === 'fonts'" class="fonts-tab">
1021
+ <!-- Detected requirements — compact summary bar -->
1022
+ <div v-if="detectedFontRequirements" class="fonts-detected">
1023
+ <div class="fonts-detected-inner">
1024
+ <span class="fonts-detected-label">Detected</span>
1025
+ <span v-for="w in detectedFontRequirements.weights" :key="`w-${w}`" class="fonts-chip">{{ w }}</span>
1026
+ <span v-for="s in detectedFontRequirements.styles" :key="`s-${s}`" class="fonts-chip">{{ s }}</span>
1027
+ <template v-if="detectedFontRequirements.families?.length">
1028
+ <span class="fonts-detected-sep" />
1029
+ <span
1030
+ v-for="f in detectedFontRequirements.families"
1031
+ :key="f"
1032
+ class="fonts-chip"
1033
+ :class="unresolvedFamilies.includes(f) ? 'fonts-chip--error' : 'fonts-chip--family'"
1034
+ >
1035
+ <UIcon v-if="unresolvedFamilies.includes(f)" name="carbon:warning-filled" class="w-2.5 h-2.5 shrink-0" />
1036
+ {{ f }}
1037
+ </span>
1038
+ </template>
1039
+ </div>
1040
+ </div>
1041
+
1042
+ <!-- Resolved fonts actually used for rendering -->
1043
+ <div v-if="resolvedFamilyNames.length" class="fonts-detected fonts-resolved">
1044
+ <div class="fonts-detected-inner">
1045
+ <span class="fonts-detected-label">Rendering</span>
1046
+ <span v-for="f in resolvedFamilyNames" :key="f" class="fonts-chip fonts-chip--family">{{ f }}</span>
1047
+ </div>
1048
+ </div>
1049
+
1050
+ <!-- Font specimen list -->
1051
+ <div v-if="fontFiles.length" class="fonts-specimen">
1052
+ <template v-for="family in fontFamilyNames" :key="family">
1053
+ <button
1054
+ class="fonts-family-row"
1055
+ :class="{ active: fontOverride === family }"
1056
+ @click="applyFontOverride(family)"
1057
+ >
1058
+ <span class="fonts-family-name" :style="{ fontFamily: `'ogp-${family}', sans-serif` }">{{ family }}</span>
1059
+ <span class="fonts-family-meta">
1060
+ <span v-if="fontFamilySizes.get(family)" class="fonts-family-size">{{ formatBytes(fontFamilySizes.get(family)!) }}</span>
1061
+ <UIcon v-if="fontOverride === family" name="carbon:checkmark-filled" class="fonts-family-check" />
1062
+ </span>
1063
+ </button>
1064
+ <div class="fonts-variants">
1065
+ <div
1066
+ v-for="f in fontFiles.filter(ff => ff.family === family)"
1067
+ :key="f.key"
1068
+ class="fonts-variant"
1069
+ :class="{ loaded: f.loaded }"
1070
+ >
1071
+ <span class="fonts-variant-dot" />
1072
+ <span class="fonts-variant-label">{{ f.weight }}{{ f.style === 'italic' ? 'i' : '' }}</span>
1073
+ <span v-if="f.subsetCount > 1" class="fonts-variant-count">&times;{{ f.subsetCount }}</span>
1074
+ <template v-if="f.subsets.length">
1075
+ <span v-for="s in f.subsets" :key="s" class="fonts-subset-chip">{{ s }}</span>
1076
+ </template>
1077
+ </div>
1078
+ </div>
1079
+ </template>
1080
+ </div>
1081
+
1082
+ <div v-else class="fonts-empty">
1083
+ No fonts resolved. Install <code class="inline-code">@nuxt/fonts</code> to enable.
1084
+ </div>
1085
+ </div>
1086
+
1087
+ <!-- useSeoMeta snippet for meta tag overrides -->
1088
+ <DevtoolsSnippet
1089
+ v-if="protoTab === 'meta-tags' && hasMetaOverrides"
1090
+ label="useSeoMeta"
1091
+ :code="seoMetaSnippet"
1092
+ lang="js"
1093
+ />
1094
+
1095
+ <UAlert
1096
+ v-else-if="hasMadeChanges && protoTab !== 'meta-tags'"
1097
+ color="warning"
1098
+ variant="subtle"
1099
+ icon="carbon:warning"
1100
+ title="Unsaved changes"
1101
+ description="These changes are for preview only and won't persist."
1102
+ class="mx-3 my-2"
1103
+ />
1104
+ </DevtoolsPanel>
1105
+ </Transition>
1106
+ </div>
1107
+ </div>
1108
+ </template>
1109
+
1110
+ <style scoped>
1111
+ .preview-container {
1112
+ height: calc(100vh - 100px);
1113
+ min-height: 500px;
1114
+ overflow: hidden;
1115
+ display: flex;
1116
+ flex-direction: column;
1117
+ }
1118
+
1119
+ .preview-container:hover {
1120
+ border-color: var(--color-border);
1121
+ box-shadow: none;
1122
+ }
1123
+
1124
+ @media (max-height: 600px) {
1125
+ .preview-container {
1126
+ height: 100vh;
1127
+ min-height: 0;
1128
+ border-radius: 0;
1129
+ border-left: 0;
1130
+ border-right: 0;
1131
+ }
1132
+ }
1133
+
1134
+ /* Preview source toggle */
1135
+ /* Component info */
1136
+ .component-info {
1137
+ position: absolute;
1138
+ left: 50%;
1139
+ top: 50%;
1140
+ transform: translate(-50%, -50%);
1141
+ display: flex;
1142
+ align-items: center;
1143
+ gap: 0.5rem;
1144
+ font-size: 0.8125rem;
1145
+ }
1146
+
1147
+ .component-path-link {
1148
+ font-size: 0.8125rem;
1149
+ font-family: var(--font-mono, ui-monospace, monospace);
1150
+ }
1151
+
1152
+ /* Size variant bar */
1153
+ .size-variant-bar {
1154
+ display: flex;
1155
+ align-items: center;
1156
+ gap: 0.5rem;
1157
+ padding: 0.375rem 0.75rem;
1158
+ border-bottom: 1px solid var(--color-border);
1159
+ background: var(--color-surface-elevated);
1160
+ }
1161
+
1162
+ @media (min-width: 640px) {
1163
+ .size-variant-bar {
1164
+ padding: 0.375rem 1rem;
1165
+ }
1166
+ }
1167
+
1168
+ .size-variant-label {
1169
+ font-size: 0.6875rem;
1170
+ color: var(--color-text-subtle);
1171
+ letter-spacing: 0.02em;
1172
+ white-space: nowrap;
1173
+ }
1174
+
1175
+ .size-variant-group {
1176
+ display: flex;
1177
+ gap: 1px;
1178
+ background: var(--color-border);
1179
+ border-radius: 6px;
1180
+ overflow: hidden;
1181
+ }
1182
+
1183
+ .size-variant-btn {
1184
+ display: flex;
1185
+ align-items: center;
1186
+ gap: 0.25rem;
1187
+ padding: 0.25rem 0.5rem;
1188
+ font-size: 0.6875rem;
1189
+ line-height: 1;
1190
+ background: var(--color-surface);
1191
+ color: var(--color-text-muted);
1192
+ border: none;
1193
+ cursor: pointer;
1194
+ transition: background 120ms ease, color 120ms ease;
1195
+ white-space: nowrap;
1196
+ }
1197
+
1198
+ .size-variant-btn:hover {
1199
+ background: var(--color-surface-sunken);
1200
+ color: var(--color-text);
1201
+ }
1202
+
1203
+ .size-variant-btn.active {
1204
+ background: var(--color-surface-sunken);
1205
+ color: var(--color-text);
1206
+ }
1207
+
1208
+ .size-variant-name {
1209
+ font-weight: 500;
1210
+ }
1211
+
1212
+ .size-variant-px {
1213
+ font-family: var(--font-mono, ui-monospace, monospace);
1214
+ font-size: 0.625rem;
1215
+ opacity: 0.6;
1216
+ }
1217
+
1218
+ .size-variant-btn.active .size-variant-px {
1219
+ opacity: 0.8;
1220
+ }
1221
+
1222
+ /* Preview area */
1223
+ .preview-area {
1224
+ flex: 1;
1225
+ display: flex;
1226
+ align-items: center;
1227
+ justify-content: center;
1228
+ padding: 1rem;
1229
+ overflow: auto;
1230
+ position: relative;
1231
+ transition: padding 200ms cubic-bezier(0.22, 1, 0.36, 1);
1232
+ }
1233
+
1234
+ @media (min-width: 640px) {
1235
+ .preview-area {
1236
+ padding: 1.5rem;
1237
+ }
1238
+
1239
+ .preview-area--panel-open {
1240
+ padding-right: 20rem;
1241
+ }
1242
+ }
1243
+
1244
+ .preview-content {
1245
+ width: 100%;
1246
+ max-width: 42rem;
1247
+ margin: 0 auto;
1248
+ transition: max-width 200ms cubic-bezier(0.22, 1, 0.36, 1);
1249
+ }
1250
+
1251
+ .preview-content--no-transition {
1252
+ transition: none;
1253
+ }
1254
+
1255
+ @media (max-height: 600px) {
1256
+ .preview-area {
1257
+ padding: 0.5rem;
1258
+ overflow: hidden;
1259
+ }
1260
+
1261
+ .preview-content {
1262
+ max-width: 100%;
1263
+ /* Scale cards to fit available height — toolbar ~40px, tabs ~36px, padding 16px */
1264
+ max-height: calc(100vh - 160px);
1265
+ display: flex;
1266
+ flex-direction: column;
1267
+ justify-content: center;
1268
+ }
1269
+
1270
+ /* Constrain all card renderers to fit viewport height */
1271
+ .preview-content :deep(img) {
1272
+ max-height: calc(100vh - 280px);
1273
+ width: auto;
1274
+ object-fit: contain;
1275
+ }
1276
+
1277
+ .preview-content :deep(.discord-image),
1278
+ .preview-content :deep(.facebook-image),
1279
+ .preview-content :deep(.linkedin-image),
1280
+ .preview-content :deep(.bluesky-image),
1281
+ .preview-content :deep(.slack-image) {
1282
+ max-height: calc(100vh - 280px);
1283
+ }
1284
+ }
1285
+
1286
+ .raw-preview-wrapper {
1287
+ position: relative;
1288
+ width: 100%;
1289
+ }
1290
+
1291
+ .raw-preview {
1292
+ width: 100%;
1293
+ border-radius: var(--radius-lg);
1294
+ overflow: hidden;
1295
+ box-shadow: 0 4px 24px oklch(0% 0 0 / 0.08);
1296
+ }
1297
+
1298
+ .raw-preview--dragging {
1299
+ user-select: none;
1300
+ }
1301
+
1302
+ .dark .raw-preview {
1303
+ box-shadow: 0 4px 24px oklch(0% 0 0 / 0.3);
1304
+ }
1305
+
1306
+ /* Resize drag handle — right edge */
1307
+ .raw-resize-handle {
1308
+ position: absolute;
1309
+ top: 0;
1310
+ right: -14px;
1311
+ width: 28px;
1312
+ height: 100%;
1313
+ cursor: col-resize;
1314
+ display: flex;
1315
+ align-items: center;
1316
+ justify-content: center;
1317
+ z-index: 2;
1318
+ }
1319
+
1320
+ .raw-resize-grip {
1321
+ width: 4px;
1322
+ height: 32px;
1323
+ border-radius: 2px;
1324
+ background: var(--color-border);
1325
+ transition: background 150ms ease, height 150ms ease;
1326
+ }
1327
+
1328
+ .raw-resize-handle:hover .raw-resize-grip,
1329
+ .raw-preview--dragging ~ .raw-resize-handle .raw-resize-grip {
1330
+ background: var(--color-text-subtle);
1331
+ height: 48px;
1332
+ }
1333
+
1334
+ /* Width readout pill */
1335
+ .raw-resize-readout {
1336
+ position: absolute;
1337
+ bottom: -24px;
1338
+ left: 50%;
1339
+ transform: translateX(-50%);
1340
+ font-family: var(--font-mono, ui-monospace, monospace);
1341
+ font-size: 0.625rem;
1342
+ color: var(--color-text-subtle);
1343
+ white-space: nowrap;
1344
+ }
1345
+
1346
+ /* Status line */
1347
+ .status-line {
1348
+ margin-top: 1rem;
1349
+ text-align: center;
1350
+ font-size: 0.75rem;
1351
+ color: var(--color-text-subtle);
1352
+ }
1353
+
1354
+ @media (max-height: 600px) {
1355
+ .status-line {
1356
+ margin-top: 0.375rem;
1357
+ font-size: 0.6875rem;
1358
+ }
1359
+ }
1360
+
1361
+ /* Props panel — positioning only, visuals from DevtoolsPanel */
1362
+ .props-panel {
1363
+ position: absolute;
1364
+ right: 0.75rem;
1365
+ top: 7rem;
1366
+ bottom: 0.75rem;
1367
+ width: 18rem;
1368
+ z-index: 10;
1369
+ }
1370
+
1371
+ @media (min-width: 640px) {
1372
+ .props-panel {
1373
+ right: 1rem;
1374
+ top: 8rem;
1375
+ bottom: 1rem;
1376
+ }
1377
+ }
1378
+
1379
+ @media (max-height: 600px) {
1380
+ .props-panel {
1381
+ top: 5.5rem;
1382
+ bottom: 0.25rem;
1383
+ right: 0.25rem;
1384
+ border-radius: var(--radius-md);
1385
+ }
1386
+ }
1387
+
1388
+ .props-field {
1389
+ padding: 0.375rem 0.75rem;
1390
+ }
1391
+
1392
+ .props-field-label {
1393
+ display: flex;
1394
+ align-items: center;
1395
+ gap: 0.375rem;
1396
+ font-size: 0.6875rem;
1397
+ font-weight: 500;
1398
+ color: var(--color-text-muted);
1399
+ margin-bottom: 0.25rem;
1400
+ text-transform: uppercase;
1401
+ letter-spacing: 0.03em;
1402
+ }
1403
+
1404
+ /* Fonts tab */
1405
+ .fonts-tab {
1406
+ display: flex;
1407
+ flex-direction: column;
1408
+ }
1409
+
1410
+ /* Detected requirements — compact summary bar */
1411
+ .fonts-detected {
1412
+ padding: 0.5rem 0.75rem;
1413
+ border-bottom: 1px solid var(--color-border-subtle);
1414
+ }
1415
+
1416
+ .fonts-detected-inner {
1417
+ display: flex;
1418
+ align-items: center;
1419
+ flex-wrap: wrap;
1420
+ gap: 0.25rem;
1421
+ }
1422
+
1423
+ .fonts-detected-label {
1424
+ font-family: var(--font-mono);
1425
+ font-size: 0.5625rem;
1426
+ font-weight: 600;
1427
+ text-transform: uppercase;
1428
+ letter-spacing: 0.05em;
1429
+ color: var(--color-text-subtle);
1430
+ margin-right: 0.125rem;
1431
+ }
1432
+
1433
+ .fonts-chip {
1434
+ display: inline-flex;
1435
+ align-items: center;
1436
+ gap: 0.1875rem;
1437
+ padding: 0.0625rem 0.3125rem;
1438
+ font-family: var(--font-mono);
1439
+ font-size: 0.625rem;
1440
+ line-height: 1.4;
1441
+ border-radius: 3px;
1442
+ background: var(--color-surface-sunken);
1443
+ border: 1px solid var(--color-border-subtle);
1444
+ color: var(--color-text-muted);
1445
+ }
1446
+
1447
+ .fonts-chip--family {
1448
+ color: var(--seo-green);
1449
+ border-color: oklch(65% 0.2 145 / 0.2);
1450
+ background: oklch(65% 0.2 145 / 0.06);
1451
+ }
1452
+
1453
+ .dark .fonts-chip--family {
1454
+ background: oklch(65% 0.2 145 / 0.1);
1455
+ }
1456
+
1457
+ .fonts-chip--error {
1458
+ color: oklch(55% 0.2 25);
1459
+ border-color: oklch(55% 0.2 25 / 0.25);
1460
+ background: oklch(55% 0.2 25 / 0.08);
1461
+ }
1462
+
1463
+ .dark .fonts-chip--error {
1464
+ color: oklch(75% 0.15 25);
1465
+ background: oklch(55% 0.2 25 / 0.12);
1466
+ }
1467
+
1468
+ .fonts-resolved {
1469
+ border-bottom: none;
1470
+ padding-top: 0;
1471
+ }
1472
+
1473
+ .fonts-detected + .fonts-resolved {
1474
+ padding-top: 0;
1475
+ }
1476
+
1477
+ .fonts-detected-sep {
1478
+ width: 1px;
1479
+ height: 0.75rem;
1480
+ background: var(--color-border);
1481
+ margin: 0 0.125rem;
1482
+ flex-shrink: 0;
1483
+ }
1484
+
1485
+ /* Font specimen list */
1486
+ .fonts-specimen {
1487
+ display: flex;
1488
+ flex-direction: column;
1489
+ }
1490
+
1491
+ .fonts-family-row {
1492
+ display: flex;
1493
+ align-items: center;
1494
+ justify-content: space-between;
1495
+ width: 100%;
1496
+ padding: 0.375rem 0.75rem;
1497
+ background: transparent;
1498
+ border: none;
1499
+ cursor: pointer;
1500
+ transition: background 150ms cubic-bezier(0.22, 1, 0.36, 1);
1501
+ }
1502
+
1503
+ .fonts-family-row:hover {
1504
+ background: var(--color-surface-sunken);
1505
+ }
1506
+
1507
+ .fonts-family-row.active {
1508
+ background: oklch(65% 0.2 145 / 0.06);
1509
+ }
1510
+
1511
+ .dark .fonts-family-row.active {
1512
+ background: oklch(65% 0.2 145 / 0.08);
1513
+ }
1514
+
1515
+ .fonts-family-name {
1516
+ font-size: 0.8125rem;
1517
+ font-weight: 500;
1518
+ color: var(--color-text);
1519
+ line-height: 1.3;
1520
+ }
1521
+
1522
+ .fonts-family-row.active .fonts-family-name {
1523
+ color: var(--seo-green);
1524
+ }
1525
+
1526
+ .fonts-family-meta {
1527
+ display: flex;
1528
+ align-items: center;
1529
+ gap: 0.375rem;
1530
+ flex-shrink: 0;
1531
+ }
1532
+
1533
+ .fonts-family-size {
1534
+ font-family: var(--font-mono);
1535
+ font-size: 0.5625rem;
1536
+ color: var(--color-text-subtle);
1537
+ letter-spacing: 0.01em;
1538
+ }
1539
+
1540
+ .fonts-family-check {
1541
+ width: 0.875rem;
1542
+ height: 0.875rem;
1543
+ color: var(--seo-green);
1544
+ flex-shrink: 0;
1545
+ }
1546
+
1547
+ .fonts-variants {
1548
+ display: flex;
1549
+ flex-wrap: wrap;
1550
+ gap: 0.375rem 0.625rem;
1551
+ padding: 0.125rem 0.75rem 0.5rem 1rem;
1552
+ border-bottom: 1px solid var(--color-border-subtle);
1553
+ }
1554
+
1555
+ .fonts-variant {
1556
+ display: flex;
1557
+ align-items: baseline;
1558
+ gap: 0.25rem;
1559
+ flex-wrap: wrap;
1560
+ }
1561
+
1562
+ .fonts-variant-dot {
1563
+ width: 5px;
1564
+ height: 5px;
1565
+ border-radius: 50%;
1566
+ flex-shrink: 0;
1567
+ background: var(--color-border);
1568
+ align-self: center;
1569
+ }
1570
+
1571
+ .fonts-variant.loaded .fonts-variant-dot {
1572
+ background: var(--seo-green);
1573
+ }
1574
+
1575
+ .fonts-variant-label {
1576
+ font-family: var(--font-mono);
1577
+ font-size: 0.625rem;
1578
+ color: var(--color-text-subtle);
1579
+ line-height: 1;
1580
+ }
1581
+
1582
+ .fonts-variant.loaded .fonts-variant-label {
1583
+ color: var(--color-text-muted);
1584
+ }
1585
+
1586
+ .fonts-variant-count {
1587
+ font-family: var(--font-mono);
1588
+ font-size: 0.5625rem;
1589
+ color: var(--color-text-subtle);
1590
+ opacity: 0.7;
1591
+ }
1592
+
1593
+ .fonts-subset-chip {
1594
+ font-family: var(--font-mono);
1595
+ font-size: 0.5rem;
1596
+ line-height: 1;
1597
+ padding: 0.0625rem 0.1875rem;
1598
+ border-radius: 2px;
1599
+ background: var(--color-surface-sunken);
1600
+ border: 1px solid var(--color-border-subtle);
1601
+ color: var(--color-text-subtle);
1602
+ }
1603
+
1604
+ .fonts-variant.loaded .fonts-subset-chip {
1605
+ color: var(--color-text-muted);
1606
+ }
1607
+
1608
+ .fonts-empty {
1609
+ padding: 1rem 0.75rem;
1610
+ font-size: 0.75rem;
1611
+ color: var(--color-text-subtle);
1612
+ text-align: center;
1613
+ }
1614
+
1615
+ /* Prop editor */
1616
+ .prop-editor {
1617
+ display: flex;
1618
+ flex-direction: column;
1619
+ gap: 0.375rem;
1620
+ padding: 0.5rem 0.75rem;
1621
+ }
1622
+
1623
+ .prop-field {
1624
+ display: flex;
1625
+ flex-direction: column;
1626
+ gap: 0.125rem;
1627
+ }
1628
+
1629
+ .prop-label {
1630
+ font-family: var(--font-mono);
1631
+ font-size: 0.6875rem;
1632
+ color: var(--color-text-subtle);
1633
+ letter-spacing: 0.01em;
1634
+ }
1635
+
1636
+ .prop-input {
1637
+ font-family: var(--font-mono);
1638
+ font-size: 0.75rem;
1639
+ padding: 0.25rem 0.375rem;
1640
+ border-radius: var(--radius-sm);
1641
+ border: 1px solid var(--color-border-subtle);
1642
+ background: var(--color-surface-sunken);
1643
+ color: var(--color-text);
1644
+ outline: none;
1645
+ transition: border-color 150ms;
1646
+ }
1647
+
1648
+ .prop-input:focus {
1649
+ border-color: var(--seo-green);
1650
+ }
1651
+
1652
+ .prop-input-color {
1653
+ width: 2.5rem;
1654
+ height: 1.5rem;
1655
+ padding: 0.125rem;
1656
+ cursor: pointer;
1657
+ }
1658
+
1659
+ .prop-input-json {
1660
+ resize: vertical;
1661
+ min-height: 2.5rem;
1662
+ line-height: 1.4;
1663
+ }
1664
+
1665
+ .prop-editor-empty {
1666
+ padding: 1rem 0.75rem;
1667
+ font-size: 0.75rem;
1668
+ color: var(--color-text-subtle);
1669
+ text-align: center;
1670
+ }
1671
+
1672
+ /* Inline code */
1673
+ .inline-code {
1674
+ padding: 0.125rem 0.375rem;
1675
+ border-radius: var(--radius-sm);
1676
+ background: var(--color-surface-sunken);
1677
+ border: 1px solid var(--color-border-subtle);
1678
+ font-family: var(--font-mono);
1679
+ font-size: 0.8125rem;
1680
+ color: var(--seo-green);
1681
+ }
1682
+ </style>