nuxt-seo-utils 8.2.0 → 8.3.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 (51) hide show
  1. package/dist/cli.mjs +1 -1
  2. package/dist/devtools/lib/seo-utils/rpc.ts +3 -0
  3. package/dist/devtools/lib/seo-utils/state.ts +35 -0
  4. package/dist/devtools/lib/seo-utils/tools.ts +98 -0
  5. package/dist/devtools/nuxt.config.ts +3 -0
  6. package/dist/devtools/pages/seo-utils/debug.vue +50 -0
  7. package/dist/devtools/pages/seo-utils/docs.vue +3 -0
  8. package/dist/devtools/pages/seo-utils/identity.vue +1168 -0
  9. package/dist/devtools/pages/seo-utils/index.vue +609 -0
  10. package/dist/devtools/pages/seo-utils/social.vue +515 -0
  11. package/dist/devtools/pages/seo-utils.vue +64 -0
  12. package/dist/module.json +1 -1
  13. package/dist/module.mjs +2 -2
  14. package/dist/runtime/app/composables/polyfills.js +1 -1
  15. package/dist/runtime/app/composables/useBreadcrumbItems.js +3 -3
  16. package/dist/runtime/app/composables/useFallbackTitle.js +1 -1
  17. package/dist/runtime/app/composables/useShareLinks.js +2 -2
  18. package/dist/runtime/app/logic/applyDefaults.js +2 -2
  19. package/dist/runtime/app/plugins/1.absoluteImageUrls.server.js +1 -1
  20. package/dist/runtime/app/plugins/siteConfig.js +1 -1
  21. package/dist/runtime/server/middleware/redirectCanonical.js +1 -1
  22. package/dist/runtime/server/middleware/resolveImagesInPagesDir.js +1 -1
  23. package/dist/runtime/server/routes/__nuxt-seo-utils/debug.d.ts +1 -1
  24. package/dist/runtime/server/routes/__nuxt-seo-utils/debug.js +1 -1
  25. package/package.json +32 -31
  26. package/dist/devtools/200.html +0 -1
  27. package/dist/devtools/404.html +0 -1
  28. package/dist/devtools/_nuxt/BEw_EXq1.js +0 -14
  29. package/dist/devtools/_nuxt/BgCgKrEE.js +0 -1
  30. package/dist/devtools/_nuxt/C4gkMyix.js +0 -1
  31. package/dist/devtools/_nuxt/CQ4Jy7Nm.js +0 -1
  32. package/dist/devtools/_nuxt/DcgcSEa-.js +0 -33
  33. package/dist/devtools/_nuxt/DeIiaCYF.js +0 -1
  34. package/dist/devtools/_nuxt/DevtoolsMetric.DabK8i8g.css +0 -1
  35. package/dist/devtools/_nuxt/DfZjB7w2.js +0 -1
  36. package/dist/devtools/_nuxt/DjEWf-n1.js +0 -1
  37. package/dist/devtools/_nuxt/builds/latest.json +0 -1
  38. package/dist/devtools/_nuxt/builds/meta/21c3fe25-8926-44ca-9d37-f478243d148a.json +0 -1
  39. package/dist/devtools/_nuxt/debug.B9T5jmI-.css +0 -1
  40. package/dist/devtools/_nuxt/entry.aDw8hoj_.css +0 -1
  41. package/dist/devtools/_nuxt/fira-code.Bc8wnsZt.woff2 +0 -0
  42. package/dist/devtools/_nuxt/hubot-sans.DLGyhQVu.woff2 +0 -0
  43. package/dist/devtools/_nuxt/identity.Dl98KbLG.css +0 -1
  44. package/dist/devtools/_nuxt/index.BrQ8jYVK.css +0 -1
  45. package/dist/devtools/_nuxt/social.DrJTEVxX.css +0 -1
  46. package/dist/devtools/_nuxt/tools.DM91U8p4.css +0 -1
  47. package/dist/devtools/debug/index.html +0 -1
  48. package/dist/devtools/docs/index.html +0 -1
  49. package/dist/devtools/identity/index.html +0 -1
  50. package/dist/devtools/index.html +0 -1
  51. package/dist/devtools/social/index.html +0 -1
@@ -0,0 +1,609 @@
1
+ <script setup lang="ts">
2
+ import { useClipboard } from '@vueuse/core'
3
+ import { computed, ref, watch } from 'vue'
4
+ import { data } from '../../lib/seo-utils/state'
5
+ import { estimatePixelWidth, descColor as getDescColor, titleColor as getTitleColor, parseMetaTags, SEO_LIMITS } from '../../lib/seo-utils/tools'
6
+
7
+ const trailingSlashRe = /\/$/
8
+
9
+ const { copy, copied } = useClipboard()
10
+
11
+ const loading = ref(false)
12
+ const error = ref<string | null>(null)
13
+ const result = ref<ReturnType<typeof parseMetaTags> & { url: string } | null>(null)
14
+
15
+ const titleLen = computed(() => result.value?.title.length || 0)
16
+ const descLen = computed(() => result.value?.description.length || 0)
17
+ const tColor = computed(() => getTitleColor(titleLen.value))
18
+ const dColor = computed(() => getDescColor(descLen.value))
19
+
20
+ const displayUrl = computed(() => {
21
+ if (!result.value?.url)
22
+ return ''
23
+ try {
24
+ const url = new URL(result.value.url)
25
+ return url.hostname + (url.pathname === '/' ? '' : url.pathname.replace(trailingSlashRe, ''))
26
+ }
27
+ catch {
28
+ return result.value.url
29
+ }
30
+ })
31
+
32
+ const essentialTags = computed(() => {
33
+ if (!result.value)
34
+ return []
35
+ return [
36
+ { category: 'Basic', name: 'title', present: !!result.value.title, value: result.value.title },
37
+ { category: 'Basic', name: 'description', present: !!result.value.description, value: result.value.description },
38
+ { category: 'Basic', name: 'canonical', present: !!result.value.canonical, value: result.value.canonical },
39
+ { category: 'Open Graph', name: 'og:title', present: !!result.value.ogTags['og:title'], value: result.value.ogTags['og:title'] },
40
+ { category: 'Open Graph', name: 'og:description', present: !!result.value.ogTags['og:description'], value: result.value.ogTags['og:description'] },
41
+ { category: 'Open Graph', name: 'og:image', present: !!result.value.ogTags['og:image'], value: result.value.ogTags['og:image'] },
42
+ { category: 'Open Graph', name: 'og:url', present: !!result.value.ogTags['og:url'], value: result.value.ogTags['og:url'] },
43
+ { category: 'Twitter', name: 'twitter:card', present: !!result.value.twitterTags['twitter:card'], value: result.value.twitterTags['twitter:card'] },
44
+ { category: 'Twitter', name: 'twitter:title', present: !!result.value.twitterTags['twitter:title'], value: result.value.twitterTags['twitter:title'], fallback: result.value.ogTags['og:title'] ? 'og:title' : undefined },
45
+ { category: 'Twitter', name: 'twitter:image', present: !!result.value.twitterTags['twitter:image'], value: result.value.twitterTags['twitter:image'], fallback: result.value.ogTags['og:image'] ? 'og:image' : undefined },
46
+ ]
47
+ })
48
+
49
+ const essentialTagsByCategory = computed(() => {
50
+ const grouped: Record<string, typeof essentialTags.value> = {}
51
+ essentialTags.value.forEach((tag) => {
52
+ if (!grouped[tag.category])
53
+ grouped[tag.category] = []
54
+ grouped[tag.category]!.push(tag)
55
+ })
56
+ return grouped
57
+ })
58
+
59
+ const essentialTagsScore = computed(() => {
60
+ const present = essentialTags.value.filter(t => t.present || t.fallback).length
61
+ return { present, total: essentialTags.value.length }
62
+ })
63
+
64
+ const scorePercent = computed(() => {
65
+ const { present, total } = essentialTagsScore.value
66
+ return total > 0 ? Math.round((present / total) * 100) : 0
67
+ })
68
+
69
+ const missingTags = computed(() => {
70
+ if (!result.value)
71
+ return []
72
+ const missing: { tag: string, severity: 'error' | 'warning', message: string }[] = []
73
+ if (!result.value.title)
74
+ missing.push({ tag: 'title', severity: 'error', message: 'No title tag found.' })
75
+ if (!result.value.description)
76
+ missing.push({ tag: 'meta description', severity: 'warning', message: 'Missing meta description.' })
77
+ if (!result.value.ogTags['og:title'])
78
+ missing.push({ tag: 'og:title', severity: 'warning', message: 'Missing og:title for social sharing.' })
79
+ if (!result.value.ogTags['og:description'])
80
+ missing.push({ tag: 'og:description', severity: 'warning', message: 'Missing og:description for social sharing.' })
81
+ if (!result.value.ogTags['og:image'])
82
+ missing.push({ tag: 'og:image', severity: 'warning', message: 'Missing og:image. Posts with images get higher click rates.' })
83
+ if (!result.value.canonical)
84
+ missing.push({ tag: 'canonical', severity: 'warning', message: 'Missing canonical URL.' })
85
+ return missing
86
+ })
87
+
88
+ const overallStatus = computed(() => {
89
+ if (!result.value)
90
+ return null
91
+ const hasErrors = missingTags.value.some(t => t.severity === 'error') || tColor.value === 'error' || dColor.value === 'error'
92
+ const hasWarnings = missingTags.value.length > 0 || tColor.value === 'warning' || dColor.value === 'warning'
93
+ if (hasErrors)
94
+ return { variant: 'warning' as const, message: 'Issues found', icon: 'carbon:warning-alt' }
95
+ if (hasWarnings)
96
+ return { variant: 'warning' as const, message: 'Some suggestions', icon: 'carbon:warning' }
97
+ return { variant: 'success' as const, message: 'Looking good!', icon: 'carbon:checkmark-filled' }
98
+ })
99
+
100
+ function lengthStatusLabel(color: string): string {
101
+ if (color === 'success')
102
+ return 'Good'
103
+ if (color === 'warning')
104
+ return 'Needs work'
105
+ return 'Too long'
106
+ }
107
+
108
+ async function checkCurrentPage() {
109
+ loading.value = true
110
+ error.value = null
111
+ result.value = null
112
+
113
+ try {
114
+ const baseUrl = window.parent?.location?.origin || window.location.origin
115
+ const response = await fetch(`${baseUrl}${path.value}`, {
116
+ headers: { Accept: 'text/html' },
117
+ })
118
+ if (!response.ok)
119
+ throw new Error(`HTTP ${response.status}`)
120
+ const html = await response.text()
121
+ const parsed = parseMetaTags(html)
122
+ result.value = { ...parsed, url: `${baseUrl}${path.value}` }
123
+ }
124
+ catch (e: any) {
125
+ error.value = e.message || 'Failed to fetch page'
126
+ }
127
+ finally {
128
+ loading.value = false
129
+ }
130
+ }
131
+
132
+ watch([() => data.value, path, refreshTime], () => {
133
+ if (data.value)
134
+ checkCurrentPage()
135
+ }, { immediate: true })
136
+ </script>
137
+
138
+ <template>
139
+ <div class="space-y-5 stagger-children">
140
+ <!-- Loading -->
141
+ <DevtoolsLoading v-if="loading" />
142
+
143
+ <!-- Error -->
144
+ <DevtoolsError v-else-if="error" :error="error" title="Failed to check page">
145
+ <UButton size="sm" variant="ghost" icon="carbon:reset" label="Retry" @click="checkCurrentPage" />
146
+ </DevtoolsError>
147
+
148
+ <!-- Results -->
149
+ <template v-else-if="result">
150
+ <!-- Overall status with score ring -->
151
+ <div class="seo-status-card" :class="`seo-status-card--${overallStatus?.variant}`">
152
+ <div class="seo-score-ring">
153
+ <svg viewBox="0 0 36 36" class="seo-score-ring__svg">
154
+ <circle cx="18" cy="18" r="15.5" fill="none" stroke="var(--color-border-subtle)" stroke-width="2.5" />
155
+ <circle
156
+ cx="18" cy="18" r="15.5" fill="none"
157
+ class="seo-score-ring__progress"
158
+ :class="`seo-score-ring__progress--${overallStatus?.variant}`"
159
+ stroke-width="2.5"
160
+ stroke-linecap="round"
161
+ :stroke-dasharray="`${scorePercent * 0.974}, 100`"
162
+ transform="rotate(-90 18 18)"
163
+ />
164
+ </svg>
165
+ <span class="seo-score-ring__text">{{ essentialTagsScore.present }}<span class="seo-score-ring__separator">/</span>{{ essentialTagsScore.total }}</span>
166
+ </div>
167
+ <div>
168
+ <p class="font-medium text-sm">
169
+ {{ overallStatus?.message }}
170
+ </p>
171
+ <p class="text-xs text-[var(--color-text-muted)]">
172
+ {{ essentialTagsScore.present }} of {{ essentialTagsScore.total }} essential tags present
173
+ </p>
174
+ </div>
175
+ </div>
176
+
177
+ <!-- SERP Preview -->
178
+ <DevtoolsSection icon="carbon:search" text="Google Search Preview">
179
+ <div class="serp-preview-container">
180
+ <div class="serp-preview">
181
+ <div class="flex items-center gap-3 mb-2">
182
+ <div class="serp-preview__favicon">
183
+ <UIcon name="carbon:earth" class="w-3.5 h-3.5 text-gray-400" />
184
+ </div>
185
+ <div>
186
+ <div class="text-xs text-gray-600 dark:text-[#bdc1c6] leading-tight">
187
+ {{ displayUrl }}
188
+ </div>
189
+ </div>
190
+ </div>
191
+ <h3 class="serp-preview__title">
192
+ {{ result.title || 'No title found' }}
193
+ </h3>
194
+ <p class="serp-preview__description">
195
+ {{ result.description || 'No description found' }}
196
+ </p>
197
+ </div>
198
+ </div>
199
+ </DevtoolsSection>
200
+
201
+ <!-- Title & Description lengths -->
202
+ <div class="grid md:grid-cols-2 gap-4">
203
+ <DevtoolsSection icon="carbon:text-short-paragraph" text="Title" :collapse="false">
204
+ <div class="space-y-3">
205
+ <div class="flex items-center justify-between">
206
+ <span class="seo-length-status" :class="`seo-length-status--${tColor}`">
207
+ {{ lengthStatusLabel(tColor) }}
208
+ </span>
209
+ <span class="text-xs font-mono text-[var(--color-text-muted)]">
210
+ {{ titleLen }}/{{ SEO_LIMITS.TITLE_MAX_CHARS }} chars · ~{{ estimatePixelWidth(result.title) }}px
211
+ </span>
212
+ </div>
213
+ <div class="seo-progress-track">
214
+ <div
215
+ class="seo-progress-fill"
216
+ :class="`seo-progress-fill--${tColor}`"
217
+ :style="{ width: `${Math.min(100, (titleLen / SEO_LIMITS.TITLE_MAX_CHARS) * 100)}%` }"
218
+ />
219
+ <div class="seo-progress-marker" :style="{ left: `${(30 / SEO_LIMITS.TITLE_MAX_CHARS) * 100}%` }" />
220
+ </div>
221
+ <p class="text-xs text-[var(--color-text-muted)] truncate">
222
+ {{ result.title || 'Not set' }}
223
+ </p>
224
+ </div>
225
+ </DevtoolsSection>
226
+
227
+ <DevtoolsSection icon="carbon:text-long-paragraph" text="Description" :collapse="false">
228
+ <div class="space-y-3">
229
+ <div class="flex items-center justify-between">
230
+ <span class="seo-length-status" :class="`seo-length-status--${dColor}`">
231
+ {{ lengthStatusLabel(dColor) }}
232
+ </span>
233
+ <span class="text-xs font-mono text-[var(--color-text-muted)]">
234
+ {{ descLen }}/{{ SEO_LIMITS.DESC_MAX_CHARS }} chars
235
+ </span>
236
+ </div>
237
+ <div class="seo-progress-track">
238
+ <div
239
+ class="seo-progress-fill"
240
+ :class="`seo-progress-fill--${dColor}`"
241
+ :style="{ width: `${Math.min(100, (descLen / SEO_LIMITS.DESC_MAX_CHARS) * 100)}%` }"
242
+ />
243
+ <div class="seo-progress-marker" :style="{ left: `${(SEO_LIMITS.DESC_WARN_CHARS / SEO_LIMITS.DESC_MAX_CHARS) * 100}%` }" />
244
+ </div>
245
+ <p class="text-xs text-[var(--color-text-muted)] truncate">
246
+ {{ result.description || 'Not set' }}
247
+ </p>
248
+ </div>
249
+ </DevtoolsSection>
250
+ </div>
251
+
252
+ <!-- Missing tags -->
253
+ <DevtoolsSection v-if="missingTags.length" icon="carbon:warning" text="Missing Tags">
254
+ <template #actions>
255
+ <DevtoolsMetric
256
+ :value="missingTags.filter(t => t.severity === 'error').length"
257
+ label="errors"
258
+ :variant="missingTags.some(t => t.severity === 'error') ? 'danger' : undefined"
259
+ />
260
+ <DevtoolsMetric
261
+ :value="missingTags.filter(t => t.severity === 'warning').length"
262
+ label="warnings"
263
+ :variant="missingTags.some(t => t.severity === 'warning') ? 'warning' : undefined"
264
+ />
265
+ </template>
266
+ <div class="space-y-1">
267
+ <div v-for="tag of missingTags" :key="tag.tag" class="seo-issue-row">
268
+ <UIcon
269
+ :name="tag.severity === 'error' ? 'carbon:close-filled' : 'carbon:warning-filled'"
270
+ class="text-base mt-0.5 shrink-0"
271
+ :class="tag.severity === 'error' ? 'text-red-500' : 'text-amber-500'"
272
+ />
273
+ <div class="min-w-0">
274
+ <span class="text-sm font-mono">{{ tag.tag }}</span>
275
+ <p class="text-xs text-[var(--color-text-muted)]">
276
+ {{ tag.message }}
277
+ </p>
278
+ </div>
279
+ </div>
280
+ </div>
281
+ </DevtoolsSection>
282
+
283
+ <!-- Essential tags checklist -->
284
+ <DevtoolsSection icon="carbon:task" text="Essential Tags Checklist">
285
+ <div class="space-y-5">
286
+ <div v-for="(tags, category) of essentialTagsByCategory" :key="category">
287
+ <div class="seo-category-header">
288
+ <span>{{ category }}</span>
289
+ </div>
290
+ <div class="seo-checklist">
291
+ <div v-for="tag of tags" :key="tag.name" class="seo-checklist__item">
292
+ <UIcon
293
+ :name="tag.present ? 'carbon:checkmark-filled' : tag.fallback ? 'carbon:information-filled' : 'carbon:close-filled'"
294
+ class="text-sm shrink-0"
295
+ :class="tag.present ? 'text-green-500' : tag.fallback ? 'text-gray-400' : 'text-red-400'"
296
+ />
297
+ <span class="text-sm font-mono flex-1 min-w-0 truncate">{{ tag.name }}</span>
298
+ <span v-if="tag.value" class="seo-checklist__value">
299
+ {{ tag.value }}
300
+ </span>
301
+ <UBadge v-else-if="tag.fallback" color="neutral" variant="subtle" size="xs">
302
+ falls back to {{ tag.fallback }}
303
+ </UBadge>
304
+ <UBadge v-else color="neutral" variant="subtle" size="xs">
305
+ missing
306
+ </UBadge>
307
+ </div>
308
+ </div>
309
+ </div>
310
+ </div>
311
+ </DevtoolsSection>
312
+
313
+ <!-- All meta tags -->
314
+ <DevtoolsSection icon="carbon:list" text="All Meta Tags">
315
+ <template #actions>
316
+ <DevtoolsMetric :value="result.allMeta.length" label="tags" />
317
+ <UButton
318
+ size="xs"
319
+ variant="ghost"
320
+ color="neutral"
321
+ icon="carbon:copy"
322
+ :label="copied ? 'Copied!' : 'Copy JSON'"
323
+ @click="copy(JSON.stringify({ title: result.title, description: result.description, canonical: result.canonical, og: result.ogTags, twitter: result.twitterTags }, null, 2))"
324
+ />
325
+ </template>
326
+ <div class="seo-meta-table">
327
+ <div v-for="(meta, i) of result.allMeta" :key="meta.name + i" class="seo-meta-table__row">
328
+ <UBadge :color="meta.type === 'property' ? 'primary' : meta.type === 'name' ? 'neutral' : 'warning'" variant="subtle" size="xs">
329
+ {{ meta.type }}
330
+ </UBadge>
331
+ <span class="text-sm font-mono shrink-0 text-[var(--color-text)]">{{ meta.name }}</span>
332
+ <span class="text-xs text-[var(--color-text-muted)] truncate min-w-0 flex-1 text-right font-mono">{{ meta.content }}</span>
333
+ </div>
334
+ </div>
335
+ </DevtoolsSection>
336
+ </template>
337
+
338
+ <!-- Not connected -->
339
+ <DevtoolsEmptyState
340
+ v-else
341
+ icon="carbon:plug"
342
+ title="Waiting for connection"
343
+ description="Waiting for connection to host app..."
344
+ />
345
+ </div>
346
+ </template>
347
+
348
+ <style scoped>
349
+ /* Status card with score ring */
350
+ .seo-status-card {
351
+ display: flex;
352
+ align-items: center;
353
+ gap: 1rem;
354
+ padding: 1rem 1.25rem;
355
+ border-radius: var(--radius-lg);
356
+ background: var(--color-surface-elevated);
357
+ border: 1px solid var(--color-border);
358
+ transition: border-color 200ms;
359
+ }
360
+
361
+ .seo-status-card--success { border-color: oklch(70% 0.15 145 / 0.4); }
362
+ .seo-status-card--warning { border-color: oklch(70% 0.15 80 / 0.4); }
363
+
364
+ /* Score ring */
365
+ .seo-score-ring {
366
+ position: relative;
367
+ width: 48px;
368
+ height: 48px;
369
+ flex-shrink: 0;
370
+ }
371
+
372
+ .seo-score-ring__svg {
373
+ width: 100%;
374
+ height: 100%;
375
+ }
376
+
377
+ .seo-score-ring__progress {
378
+ transition: stroke-dasharray 600ms cubic-bezier(0.22, 1, 0.36, 1);
379
+ }
380
+
381
+ .seo-score-ring__progress--success { stroke: oklch(65% 0.2 145); }
382
+ .seo-score-ring__progress--warning { stroke: oklch(70% 0.18 80); }
383
+
384
+ .seo-score-ring__text {
385
+ position: absolute;
386
+ inset: 0;
387
+ display: flex;
388
+ align-items: center;
389
+ justify-content: center;
390
+ font-size: 0.6875rem;
391
+ font-weight: 700;
392
+ font-family: var(--font-mono);
393
+ letter-spacing: -0.02em;
394
+ }
395
+
396
+ .seo-score-ring__separator {
397
+ opacity: 0.35;
398
+ margin: 0 0.5px;
399
+ }
400
+
401
+ /* SERP preview */
402
+ .serp-preview-container {
403
+ padding: 1.25rem;
404
+ border-radius: var(--radius-md);
405
+ background: white;
406
+ border: 1px solid oklch(0% 0 0 / 0.08);
407
+ }
408
+
409
+ .dark .serp-preview-container {
410
+ background: #202124;
411
+ border-color: oklch(100% 0 0 / 0.06);
412
+ }
413
+
414
+ .serp-preview__favicon {
415
+ width: 26px;
416
+ height: 26px;
417
+ border-radius: 50%;
418
+ display: flex;
419
+ align-items: center;
420
+ justify-content: center;
421
+ background: oklch(0% 0 0 / 0.04);
422
+ }
423
+
424
+ .dark .serp-preview__favicon {
425
+ background: oklch(100% 0 0 / 0.06);
426
+ }
427
+
428
+ .serp-preview__title {
429
+ font-size: 1.25rem;
430
+ line-height: 1.3;
431
+ color: #1a0dab;
432
+ cursor: pointer;
433
+ margin-bottom: 0.25rem;
434
+ }
435
+
436
+ .serp-preview__title:hover {
437
+ text-decoration: underline;
438
+ }
439
+
440
+ .dark .serp-preview__title {
441
+ color: #8ab4f8;
442
+ }
443
+
444
+ .serp-preview__description {
445
+ font-size: 0.875rem;
446
+ line-height: 1.58;
447
+ color: #4d5156;
448
+ display: -webkit-box;
449
+ -webkit-line-clamp: 2;
450
+ -webkit-box-orient: vertical;
451
+ overflow: hidden;
452
+ }
453
+
454
+ .dark .serp-preview__description {
455
+ color: #bdc1c6;
456
+ }
457
+
458
+ /* Length status labels */
459
+ .seo-length-status {
460
+ display: inline-flex;
461
+ align-items: center;
462
+ padding: 0.125rem 0.5rem;
463
+ border-radius: 9999px;
464
+ font-size: 0.6875rem;
465
+ font-weight: 600;
466
+ letter-spacing: 0.01em;
467
+ }
468
+
469
+ .seo-length-status--success {
470
+ background: oklch(75% 0.15 145 / 0.12);
471
+ color: oklch(50% 0.15 145);
472
+ }
473
+
474
+ .dark .seo-length-status--success {
475
+ background: oklch(50% 0.15 145 / 0.15);
476
+ color: oklch(75% 0.18 145);
477
+ }
478
+
479
+ .seo-length-status--warning {
480
+ background: oklch(75% 0.12 80 / 0.12);
481
+ color: oklch(55% 0.15 80);
482
+ }
483
+
484
+ .dark .seo-length-status--warning {
485
+ background: oklch(55% 0.12 80 / 0.15);
486
+ color: oklch(75% 0.15 80);
487
+ }
488
+
489
+ .seo-length-status--error {
490
+ background: oklch(65% 0.15 25 / 0.1);
491
+ color: oklch(55% 0.18 25);
492
+ }
493
+
494
+ .dark .seo-length-status--error {
495
+ background: oklch(50% 0.12 25 / 0.15);
496
+ color: oklch(72% 0.15 25);
497
+ }
498
+
499
+ /* Progress bar */
500
+ .seo-progress-track {
501
+ position: relative;
502
+ height: 6px;
503
+ border-radius: 3px;
504
+ background: var(--color-surface-sunken);
505
+ overflow: visible;
506
+ }
507
+
508
+ .seo-progress-fill {
509
+ height: 100%;
510
+ border-radius: 3px;
511
+ transition: width 400ms cubic-bezier(0.22, 1, 0.36, 1);
512
+ }
513
+
514
+ .seo-progress-fill--success { background: oklch(65% 0.2 145); box-shadow: 0 0 8px oklch(65% 0.2 145 / 0.3); }
515
+ .seo-progress-fill--warning { background: oklch(70% 0.18 80); box-shadow: 0 0 8px oklch(70% 0.18 80 / 0.3); }
516
+ .seo-progress-fill--error { background: oklch(62% 0.2 25); box-shadow: 0 0 8px oklch(62% 0.2 25 / 0.3); }
517
+
518
+ .seo-progress-marker {
519
+ position: absolute;
520
+ top: -2px;
521
+ width: 1px;
522
+ height: 10px;
523
+ background: var(--color-text-subtle);
524
+ opacity: 0.4;
525
+ transform: translateX(-0.5px);
526
+ }
527
+
528
+ /* Issue rows */
529
+ .seo-issue-row {
530
+ display: flex;
531
+ align-items: flex-start;
532
+ gap: 0.5rem;
533
+ padding: 0.625rem 0.75rem;
534
+ border-radius: var(--radius-sm);
535
+ transition: background 150ms;
536
+ }
537
+
538
+ .seo-issue-row:hover {
539
+ background: var(--color-surface-sunken);
540
+ }
541
+
542
+ /* Category headers */
543
+ .seo-category-header {
544
+ display: flex;
545
+ align-items: center;
546
+ gap: 0.5rem;
547
+ margin-bottom: 0.5rem;
548
+ padding-bottom: 0.375rem;
549
+ border-bottom: 1px solid var(--color-border-subtle);
550
+ }
551
+
552
+ .seo-category-header span {
553
+ font-size: 0.6875rem;
554
+ font-weight: 600;
555
+ text-transform: uppercase;
556
+ letter-spacing: 0.06em;
557
+ color: var(--color-text-muted);
558
+ }
559
+
560
+ /* Checklist */
561
+ .seo-checklist__item {
562
+ display: flex;
563
+ align-items: center;
564
+ gap: 0.5rem;
565
+ padding: 0.375rem 0.5rem;
566
+ border-radius: var(--radius-sm);
567
+ transition: background 150ms;
568
+ }
569
+
570
+ .seo-checklist__item:hover {
571
+ background: var(--color-surface-sunken);
572
+ }
573
+
574
+ .seo-checklist__value {
575
+ font-size: 0.6875rem;
576
+ font-family: var(--font-mono);
577
+ color: var(--color-text-subtle);
578
+ max-width: 200px;
579
+ overflow: hidden;
580
+ text-overflow: ellipsis;
581
+ white-space: nowrap;
582
+ }
583
+
584
+ /* Meta tags table */
585
+ .seo-meta-table__row {
586
+ display: flex;
587
+ align-items: center;
588
+ gap: 0.75rem;
589
+ padding: 0.5rem 0.625rem;
590
+ border-radius: var(--radius-sm);
591
+ transition: background 150ms;
592
+ }
593
+
594
+ .seo-meta-table__row:hover {
595
+ background: var(--color-surface-sunken);
596
+ }
597
+
598
+ .seo-meta-table__row:nth-child(even) {
599
+ background: oklch(0% 0 0 / 0.015);
600
+ }
601
+
602
+ .dark .seo-meta-table__row:nth-child(even) {
603
+ background: oklch(100% 0 0 / 0.015);
604
+ }
605
+
606
+ .seo-meta-table__row:nth-child(even):hover {
607
+ background: var(--color-surface-sunken);
608
+ }
609
+ </style>