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,197 @@
1
+ <script setup lang="ts">
2
+ import { onBeforeUnmount, ref, toValue, watch } from '#imports'
3
+ import { options } from '../../lib/og-image/util/logic'
4
+
5
+ const props = defineProps<{
6
+ src: string
7
+ aspectRatio: number
8
+ maxHeight?: number
9
+ maxWidth?: number
10
+ minHeight?: number
11
+ }>()
12
+
13
+ const emit = defineEmits(['load'])
14
+
15
+ const error = ref<string[] | false>(false)
16
+ const isLoading = ref(false)
17
+ const showSpinner = ref(false)
18
+ const loadStart = ref(0)
19
+ let spinnerTimer: ReturnType<typeof setTimeout> | undefined
20
+
21
+ watch(() => props.src, () => {
22
+ isLoading.value = true
23
+ showSpinner.value = false
24
+ error.value = false
25
+ loadStart.value = Date.now()
26
+ clearTimeout(spinnerTimer)
27
+ spinnerTimer = setTimeout(() => {
28
+ if (isLoading.value)
29
+ showSpinner.value = true
30
+ }, 200)
31
+ }, { immediate: true })
32
+
33
+ function onLoad() {
34
+ isLoading.value = false
35
+ showSpinner.value = false
36
+ clearTimeout(spinnerTimer)
37
+ emit('load', { timeTaken: Date.now() - loadStart.value, sizeKb: '' })
38
+ }
39
+
40
+ onBeforeUnmount(() => clearTimeout(spinnerTimer))
41
+
42
+ function onError() {
43
+ isLoading.value = false
44
+ showSpinner.value = false
45
+ clearTimeout(spinnerTimer)
46
+ $fetch(props.src).catch((err: { data?: { stack?: string[] } }) => {
47
+ error.value = err.data?.stack || ['Failed to load image']
48
+ })
49
+ }
50
+ </script>
51
+
52
+ <template>
53
+ <div
54
+ class="image-loader"
55
+ :style="{ aspectRatio, minHeight }"
56
+ :class="{
57
+ 'is-valid': !error && !isLoading,
58
+ 'is-error': error,
59
+ }"
60
+ >
61
+ <img
62
+ v-show="!isLoading && !error"
63
+ :src="src"
64
+ :style="{ aspectRatio, maxWidth: `${toValue(options.width) || 1200}px` }"
65
+ @load="onLoad"
66
+ @error="onError"
67
+ >
68
+
69
+ <!-- Loading state -->
70
+ <div v-if="showSpinner" class="loading-state">
71
+ <div class="loading-spinner" />
72
+ </div>
73
+
74
+ <!-- Error state -->
75
+ <div v-if="error" class="error-state">
76
+ <div class="error-header">
77
+ <UIcon name="carbon:warning" class="error-icon" />
78
+ <span class="error-type">
79
+ {{ error.join('\n').includes('satori') ? 'SatoriError' : 'ImageError' }}
80
+ </span>
81
+ </div>
82
+ <p class="error-message">
83
+ {{ error[0]?.replace('Error:', '') }}
84
+ </p>
85
+ <pre v-if="error.length > 1" class="error-stack">{{ error.slice(1).join('\n') }}</pre>
86
+ </div>
87
+ </div>
88
+ </template>
89
+
90
+ <style scoped>
91
+ .image-loader {
92
+ height: 100%;
93
+ margin: 0 auto;
94
+ width: 100%;
95
+ transition: box-shadow 300ms cubic-bezier(0.22, 1, 0.36, 1);
96
+ overflow: hidden;
97
+ background: var(--color-surface-sunken);
98
+ }
99
+
100
+ .image-loader img {
101
+ width: 100%;
102
+ height: 100%;
103
+ object-fit: contain;
104
+ background: var(--color-surface-sunken);
105
+ }
106
+
107
+ .image-loader.is-valid {
108
+ cursor: pointer;
109
+ }
110
+
111
+ .image-loader.is-valid:hover {
112
+ box-shadow: 0 4px 20px oklch(0% 0 0 / 0.1);
113
+ }
114
+
115
+ .dark .image-loader.is-valid:hover {
116
+ box-shadow: 0 4px 20px oklch(0% 0 0 / 0.3);
117
+ }
118
+
119
+ /* Loading state */
120
+ .loading-state {
121
+ display: flex;
122
+ align-items: center;
123
+ justify-content: center;
124
+ height: 100%;
125
+ min-height: 200px;
126
+ }
127
+
128
+ .loading-spinner {
129
+ width: 2rem;
130
+ height: 2rem;
131
+ border: 2px solid var(--color-border);
132
+ border-top-color: var(--seo-green);
133
+ border-radius: 50%;
134
+ animation: spin 0.8s linear infinite;
135
+ }
136
+
137
+ @keyframes spin {
138
+ to {
139
+ transform: rotate(360deg);
140
+ }
141
+ }
142
+
143
+ /* Error state */
144
+ .image-loader.is-error {
145
+ overflow-x: auto;
146
+ background: oklch(97% 0.015 25);
147
+ border: 1px solid oklch(90% 0.05 25);
148
+ }
149
+
150
+ .dark .image-loader.is-error {
151
+ background: oklch(18% 0.02 25);
152
+ border-color: oklch(28% 0.04 25);
153
+ }
154
+
155
+ .error-state {
156
+ padding: 1.25rem;
157
+ }
158
+
159
+ .error-header {
160
+ display: inline-flex;
161
+ align-items: center;
162
+ gap: 0.5rem;
163
+ padding: 0.375rem 0.75rem;
164
+ font-size: 0.75rem;
165
+ font-weight: 600;
166
+ color: oklch(50% 0.15 25);
167
+ background: oklch(92% 0.06 25);
168
+ border-radius: var(--radius-sm);
169
+ margin-bottom: 1rem;
170
+ }
171
+
172
+ .dark .error-header {
173
+ color: oklch(80% 0.1 25);
174
+ background: oklch(28% 0.06 25);
175
+ }
176
+
177
+ .error-icon {
178
+ font-size: 0.875rem;
179
+ }
180
+
181
+ .error-message {
182
+ font-size: 0.9375rem;
183
+ font-weight: 600;
184
+ margin-bottom: 1rem;
185
+ line-height: 1.5;
186
+ color: var(--color-text);
187
+ }
188
+
189
+ .error-stack {
190
+ font-size: 0.75rem;
191
+ font-family: var(--font-mono);
192
+ color: var(--color-text-muted);
193
+ white-space: pre-wrap;
194
+ word-break: break-all;
195
+ line-height: 1.6;
196
+ }
197
+ </style>
@@ -0,0 +1,100 @@
1
+ <script setup lang="ts">
2
+ defineProps<{
3
+ aspectRatio?: number
4
+ }>()
5
+ </script>
6
+
7
+ <template>
8
+ <div class="linkedin-card">
9
+ <div class="linkedin-image">
10
+ <slot />
11
+ <div class="linkedin-image-overlay" />
12
+ </div>
13
+ <div class="linkedin-content">
14
+ <p class="linkedin-title">
15
+ <slot name="title" />
16
+ </p>
17
+ <p class="linkedin-sitename">
18
+ <slot name="siteName" />
19
+ </p>
20
+ </div>
21
+ </div>
22
+ </template>
23
+
24
+ <style>
25
+ .linkedin-card {
26
+ width: 100%;
27
+ max-width: 552px;
28
+ margin: 0 auto;
29
+ border-radius: 8px;
30
+ overflow: hidden;
31
+ border: 1px solid #e0e0e0;
32
+ background: oklch(100% 0 0);
33
+ box-shadow: 0 4px 24px oklch(0% 0 0 / 0.1);
34
+ font-family: 'Hubot Sans', -apple-system, BlinkMacSystemFont, sans-serif;
35
+ }
36
+
37
+ .dark .linkedin-card {
38
+ background: #1b1f23;
39
+ border-color: oklch(28% 0.02 285);
40
+ box-shadow: 0 4px 24px oklch(0% 0 0 / 0.2);
41
+ }
42
+
43
+ .linkedin-image {
44
+ aspect-ratio: 2 / 1;
45
+ width: 100%;
46
+ position: relative;
47
+ overflow: hidden;
48
+ background: #f3f2ef;
49
+ }
50
+
51
+ .dark .linkedin-image {
52
+ background: oklch(22% 0.02 285);
53
+ }
54
+
55
+ .linkedin-image img,
56
+ .linkedin-image > *:first-child:not(.linkedin-image-overlay) {
57
+ width: 100%;
58
+ height: 100%;
59
+ object-fit: cover;
60
+ }
61
+
62
+ .linkedin-image-overlay {
63
+ position: absolute;
64
+ inset: 0;
65
+ box-shadow: inset 0 0 0 1px oklch(0% 0 0 / 0.05);
66
+ }
67
+
68
+ .dark .linkedin-image-overlay {
69
+ box-shadow: inset 0 0 0 1px oklch(100% 0 0 / 0.05);
70
+ }
71
+
72
+ .linkedin-content {
73
+ padding: 12px 14px;
74
+ }
75
+
76
+ .linkedin-title {
77
+ font-size: 14px;
78
+ font-weight: 600;
79
+ color: oklch(15% 0.02 285);
80
+ margin: 0 0 4px;
81
+ line-height: 1.3;
82
+ display: -webkit-box;
83
+ -webkit-line-clamp: 2;
84
+ -webkit-box-orient: vertical;
85
+ overflow: hidden;
86
+ }
87
+
88
+ .dark .linkedin-title {
89
+ color: oklch(95% 0.01 285);
90
+ }
91
+
92
+ .linkedin-sitename {
93
+ font-size: 12px;
94
+ color: oklch(50% 0.03 285);
95
+ margin: 0;
96
+ white-space: nowrap;
97
+ overflow: hidden;
98
+ text-overflow: ellipsis;
99
+ }
100
+ </style>
@@ -0,0 +1,356 @@
1
+ <script lang="ts" setup>
2
+ import type { RendererType } from '../../lib/og-image/runtime-types'
3
+ import { computed } from 'vue'
4
+ import { useOgImage } from '../../lib/og-image/og-image'
5
+ import { RendererSelectDialogPromise } from '../../lib/og-image/renderer-select'
6
+
7
+ const {
8
+ globalDebug,
9
+ renderer,
10
+ imageFormat,
11
+ availableRenderers,
12
+ isPageScreenshot,
13
+ getComponentVariantForRenderer,
14
+ patchOptions,
15
+ } = useOgImage()
16
+
17
+ function switchRendererAndFormat(newRenderer: RendererType, extension: 'png' | 'jpeg' | 'jpg' | 'svg' | 'html') {
18
+ const variant = getComponentVariantForRenderer(newRenderer)
19
+ if (variant) {
20
+ patchOptions({ renderer: newRenderer, extension, component: variant.pascalName })
21
+ }
22
+ else {
23
+ patchOptions({ renderer: newRenderer, extension })
24
+ }
25
+ }
26
+
27
+ const formatDescriptions: Record<string, string> = {
28
+ png: 'Lossless, best compatibility across all platforms.',
29
+ svg: 'Vector output. Smallest size, satori only.',
30
+ html: 'Raw HTML preview for debugging templates.',
31
+ jpg: 'Lossy compression. Smaller file size via Sharp.',
32
+ }
33
+
34
+ interface FormatItem {
35
+ label: string
36
+ value: string
37
+ description: string
38
+ }
39
+
40
+ interface RendererConfig {
41
+ name: RendererType
42
+ label: string
43
+ icon: string
44
+ iconType: 'component' | 'img'
45
+ description: string
46
+ available: boolean
47
+ binding: string | false
48
+ formats: FormatItem[]
49
+ disabledReason: string | null
50
+ installCommand: string | null
51
+ }
52
+
53
+ const rendererConfigs = computed<RendererConfig[]>(() => {
54
+ const compat = globalDebug.value?.compatibility
55
+ const hasSharp = compat?.sharp
56
+ const hasBrowser = availableRenderers.value.has('browser')
57
+ const hasSatori = availableRenderers.value.has('satori')
58
+ const hasTakumi = availableRenderers.value.has('takumi')
59
+
60
+ function fmt(ext: string): FormatItem {
61
+ return { label: ext.toUpperCase(), value: ext, description: formatDescriptions[ext] || '' }
62
+ }
63
+
64
+ return [
65
+ {
66
+ name: 'satori' as RendererType,
67
+ label: 'Satori',
68
+ icon: 'logos:vercel-icon',
69
+ iconType: 'component' as const,
70
+ description: 'SVG-based. Fast, works everywhere.',
71
+ available: hasSatori,
72
+ binding: compat?.satori || false,
73
+ formats: [
74
+ fmt('png'),
75
+ fmt('svg'),
76
+ ...(!isPageScreenshot.value ? [fmt('html')] : []),
77
+ ...(hasSharp ? [fmt('jpg')] : []),
78
+ ],
79
+ disabledReason: !hasSatori ? 'No satori-compatible templates found' : null,
80
+ installCommand: null,
81
+ },
82
+ {
83
+ name: 'browser' as RendererType,
84
+ label: 'Browser',
85
+ icon: 'logos:chrome',
86
+ iconType: 'component' as const,
87
+ description: 'Headless Chrome screenshot. Full CSS support.',
88
+ available: hasBrowser,
89
+ binding: compat?.browser || false,
90
+ formats: [
91
+ fmt('png'),
92
+ fmt('jpg'),
93
+ ...(!isPageScreenshot.value ? [fmt('html')] : []),
94
+ ],
95
+ disabledReason: !hasBrowser ? 'Requires Playwright or Chrome' : null,
96
+ installCommand: !hasBrowser ? 'npx playwright install chromium' : null,
97
+ },
98
+ {
99
+ name: 'takumi' as RendererType,
100
+ label: 'Takumi',
101
+ icon: 'https://takumi.kane.tw/logo.svg',
102
+ iconType: 'img' as const,
103
+ description: 'Canvas-based renderer with rich styling.',
104
+ available: hasTakumi,
105
+ binding: compat?.takumi || false,
106
+ formats: [
107
+ fmt('png'),
108
+ ...(!isPageScreenshot.value ? [fmt('html')] : []),
109
+ ...(hasSharp ? [fmt('jpg')] : []),
110
+ ],
111
+ disabledReason: !hasTakumi ? 'Module not installed' : null,
112
+ installCommand: !hasTakumi ? 'npx nuxi module add nuxt-og-image-takumi' : null,
113
+ },
114
+ ]
115
+ })
116
+
117
+ const sortedRendererConfigs = computed(() => {
118
+ return rendererConfigs.value.toSorted((a, b) => {
119
+ const aActive = renderer.value === a.name ? -1 : 0
120
+ const bActive = renderer.value === b.name ? -1 : 0
121
+ if (aActive !== bActive)
122
+ return aActive - bActive
123
+ if (a.available !== b.available)
124
+ return a.available ? -1 : 1
125
+ return 0
126
+ })
127
+ })
128
+
129
+ const takumiMigrationAvailable = computed(() => {
130
+ return renderer.value === 'satori' && availableRenderers.value.has('takumi') && getComponentVariantForRenderer('takumi')
131
+ })
132
+
133
+ function activeFormatValue(config: RendererConfig) {
134
+ if (renderer.value !== config.name)
135
+ return undefined
136
+ return imageFormat.value === 'jpeg' ? 'jpg' : (imageFormat.value || 'png')
137
+ }
138
+
139
+ function onFormatChange(config: RendererConfig, ext: string, resolve: () => void) {
140
+ switchRendererAndFormat(config.name, ext as 'png' | 'jpeg' | 'jpg' | 'svg' | 'html')
141
+ resolve()
142
+ }
143
+
144
+ function switchToTakumi(resolve: () => void) {
145
+ const variant = getComponentVariantForRenderer('takumi')
146
+ if (variant) {
147
+ patchOptions({ renderer: 'takumi', component: variant.pascalName })
148
+ resolve()
149
+ }
150
+ }
151
+ </script>
152
+
153
+ <template>
154
+ <RendererSelectDialogPromise v-slot="{ resolve }">
155
+ <UModal
156
+ :open="true"
157
+ title="Renderer & Format"
158
+ @update:open="resolve()"
159
+ @close="resolve()"
160
+ >
161
+ <template #body>
162
+ <div class="renderer-cards stagger-children">
163
+ <div
164
+ v-for="config in sortedRendererConfigs"
165
+ :key="config.name"
166
+ class="renderer-card"
167
+ :class="{
168
+ active: renderer === config.name,
169
+ disabled: !config.available,
170
+ }"
171
+ >
172
+ <!-- Header -->
173
+ <div class="renderer-card-header">
174
+ <div class="flex items-center gap-2">
175
+ <img
176
+ v-if="config.iconType === 'img'"
177
+ :src="config.icon"
178
+ class="w-4 h-4"
179
+ width="16"
180
+ height="16"
181
+ :alt="`${config.label} logo`"
182
+ >
183
+ <UIcon v-else :name="config.icon" class="w-4 h-4" aria-hidden="true" />
184
+ <span class="font-medium text-sm text-[var(--color-text)]">{{ config.label }}</span>
185
+ <UTooltip v-if="config.binding" :text="`Runtime: ${config.binding}`" :delay-duration="0">
186
+ <UBadge color="neutral" variant="subtle" size="xs" class="cursor-help">
187
+ {{ config.binding }}
188
+ </UBadge>
189
+ </UTooltip>
190
+ </div>
191
+ <UBadge v-if="renderer === config.name && config.available" color="primary" variant="soft" size="xs">
192
+ Active
193
+ </UBadge>
194
+ </div>
195
+
196
+ <!-- Description -->
197
+ <p class="text-xs text-[var(--color-text-muted)] mt-1">
198
+ {{ config.description }}
199
+ </p>
200
+
201
+ <!-- Format radio group (available) -->
202
+ <div v-if="config.available" class="format-section">
203
+ <URadioGroup
204
+ :model-value="activeFormatValue(config)"
205
+ :items="config.formats"
206
+ size="sm"
207
+ indicator="start"
208
+ @update:model-value="onFormatChange(config, $event as string, resolve)"
209
+ />
210
+ </div>
211
+
212
+ <!-- Disabled state -->
213
+ <div v-else class="disabled-info">
214
+ <p class="text-xs text-[var(--color-text-subtle)]">
215
+ {{ config.disabledReason }}
216
+ </p>
217
+ <div v-if="config.installCommand" class="install-block">
218
+ <span class="install-label">
219
+ <UIcon name="carbon:terminal" class="w-3 h-3" aria-hidden="true" />
220
+ Install
221
+ </span>
222
+ <code class="install-command">{{ config.installCommand }}</code>
223
+ </div>
224
+ </div>
225
+
226
+ <!-- Migration prompt (satori card only) -->
227
+ <div v-if="config.name === 'satori' && takumiMigrationAvailable" class="migration-prompt">
228
+ <div class="migration-prompt-inner">
229
+ <div class="flex items-center gap-1.5">
230
+ <UIcon name="carbon:arrow-right" class="w-3 h-3 text-[var(--seo-green)]" aria-hidden="true" />
231
+ <span class="text-xs text-[var(--color-text-muted)]">Takumi variant available</span>
232
+ </div>
233
+ <UButton size="xs" variant="soft" color="primary" @click="switchToTakumi(resolve)">
234
+ Switch
235
+ </UButton>
236
+ </div>
237
+ </div>
238
+ </div>
239
+ </div>
240
+ </template>
241
+ </UModal>
242
+ </RendererSelectDialogPromise>
243
+ </template>
244
+
245
+ <style scoped>
246
+ .renderer-cards {
247
+ display: flex;
248
+ flex-direction: column;
249
+ gap: 0.75rem;
250
+ }
251
+
252
+ .renderer-card {
253
+ padding: 0.875rem;
254
+ border-radius: var(--radius-lg);
255
+ border: 1px solid var(--color-border);
256
+ background: var(--color-surface-elevated);
257
+ transition: border-color 150ms, background 150ms, box-shadow 150ms;
258
+ }
259
+
260
+ .renderer-card:not(.disabled):hover {
261
+ border-color: var(--color-neutral-300);
262
+ box-shadow: 0 2px 8px oklch(0% 0 0 / 0.06);
263
+ }
264
+
265
+ :global(.dark) .renderer-card:not(.disabled):hover {
266
+ border-color: var(--color-neutral-700);
267
+ box-shadow: 0 2px 8px oklch(0% 0 0 / 0.2);
268
+ }
269
+
270
+ .renderer-card.active {
271
+ border-color: var(--seo-green);
272
+ background: oklch(65% 0.2 145 / 0.04);
273
+ }
274
+
275
+ :global(.dark) .renderer-card.active {
276
+ background: oklch(65% 0.2 145 / 0.06);
277
+ }
278
+
279
+ .renderer-card.disabled {
280
+ background: var(--color-surface-sunken);
281
+ }
282
+
283
+ .renderer-card.disabled .renderer-card-header,
284
+ .renderer-card.disabled > p {
285
+ opacity: 0.55;
286
+ }
287
+
288
+ .renderer-card-header {
289
+ display: flex;
290
+ align-items: center;
291
+ justify-content: space-between;
292
+ }
293
+
294
+ .format-section {
295
+ margin-top: 0.625rem;
296
+ }
297
+
298
+ .disabled-info {
299
+ margin-top: 0.625rem;
300
+ display: flex;
301
+ flex-direction: column;
302
+ gap: 0.375rem;
303
+ }
304
+
305
+ .install-block {
306
+ display: flex;
307
+ flex-direction: column;
308
+ gap: 0.25rem;
309
+ padding: 0.5rem;
310
+ border-radius: var(--radius-sm);
311
+ background: var(--color-surface-elevated);
312
+ border: 1px solid var(--color-border);
313
+ }
314
+
315
+ .install-label {
316
+ display: flex;
317
+ align-items: center;
318
+ gap: 0.375rem;
319
+ font-size: 0.6875rem;
320
+ font-weight: 500;
321
+ color: var(--color-text-muted);
322
+ text-transform: uppercase;
323
+ letter-spacing: 0.03em;
324
+ }
325
+
326
+ .install-command {
327
+ display: block;
328
+ padding: 0.375rem 0.5rem;
329
+ border-radius: var(--radius-sm);
330
+ background: var(--color-surface-sunken);
331
+ border: 1px solid var(--color-border-subtle);
332
+ font-family: var(--font-mono);
333
+ font-size: 0.75rem;
334
+ color: var(--color-text);
335
+ user-select: all;
336
+ }
337
+
338
+ .migration-prompt {
339
+ margin-top: 0.625rem;
340
+ padding-top: 0.625rem;
341
+ border-top: 1px solid var(--color-border);
342
+ }
343
+
344
+ .migration-prompt-inner {
345
+ display: flex;
346
+ align-items: center;
347
+ justify-content: space-between;
348
+ padding: 0.375rem 0.5rem;
349
+ border-radius: var(--radius-sm);
350
+ background: oklch(65% 0.2 145 / 0.06);
351
+ }
352
+
353
+ :global(.dark) .migration-prompt-inner {
354
+ background: oklch(65% 0.2 145 / 0.08);
355
+ }
356
+ </style>