@forsvn/metaprev 0.5.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.
package/src/render.ts ADDED
@@ -0,0 +1,980 @@
1
+ import { formatBytes } from './format.ts'
2
+ import { CARD_SUMMARY, resolvePlatformInput, resolvePrimaryInput } from './inputs.ts'
3
+ import { buildAgentPrompt, buildFindingsText, buildMetaSnippet, buildRepairBrief, resolveInputs } from './repair.ts'
4
+ import type { ImageProbe } from './types.ts'
5
+ import type { Report } from './types.ts'
6
+
7
+ /*
8
+ * The HTML preview is a self-contained, offline, single-file report. Design intent:
9
+ * register product / tool (the report serves the data; it is not a marketing page)
10
+ * audience developers verifying a share card before shipping
11
+ * tone utilitarian-editorial — calm, precise, data-first
12
+ * scene a dev glancing at the report in daylight / a bright editor → warm light
13
+ * "workbench" canvas, one terracotta accent that never collides with the
14
+ * platform brand colors, system sans + mono (zero font fetch, instant render)
15
+ *
16
+ * The platform card mocks are the product. They stay faithful to how each platform
17
+ * actually renders an OpenGraph card in 2026 (X hides title text behind a domain
18
+ * overlay; LinkedIn dropped the in-feed description; Discord auto-embeds have no color
19
+ * bar) — fidelity is the feature, so that layer is reproduced, not reinterpreted.
20
+ */
21
+
22
+ function escapeHtml(s: string | undefined): string {
23
+ if (!s) return ''
24
+ return s
25
+ .replace(/&/g, '&')
26
+ .replace(/</g, '&lt;')
27
+ .replace(/>/g, '&gt;')
28
+ .replace(/"/g, '&quot;')
29
+ .replace(/'/g, '&#39;')
30
+ }
31
+
32
+ function host(url: string | undefined): string {
33
+ if (!url) return ''
34
+ try {
35
+ return new URL(url).host.replace(/^www\./, '')
36
+ } catch {
37
+ return url
38
+ }
39
+ }
40
+
41
+ function safeHttpHref(value: string): string {
42
+ try {
43
+ const url = new URL(value)
44
+ return url.protocol === 'http:' || url.protocol === 'https:' ? escapeHtml(url.toString()) : '#'
45
+ } catch {
46
+ return '#'
47
+ }
48
+ }
49
+
50
+ function pluralize(n: number, word: string): string {
51
+ return `${n} ${word}${n === 1 ? '' : 's'}`
52
+ }
53
+
54
+ function safeDataUri(value: string | undefined): string {
55
+ if (!value) return ''
56
+ return /^data:image\/(?:png|jpeg|webp|gif|avif);base64,[a-z0-9+/=]+$/i.test(value)
57
+ ? escapeHtml(value)
58
+ : ''
59
+ }
60
+
61
+ function sourceLabel(values: Array<string | undefined>): string {
62
+ return values.filter(Boolean).join(' · ')
63
+ }
64
+
65
+ function cropEvidence(image: ImageProbe | undefined): string {
66
+ if (!image?.width || !image.height) return 'Crop cannot be calculated without decoded dimensions.'
67
+ const ratio = image.width / image.height
68
+ const target = 1200 / 630
69
+ if (Math.abs(ratio - target) / target <= 0.02) return 'Fits the 1.91:1 frame with no material crop.'
70
+ if (ratio < target) return `Cover mode hides about ${Math.round((1 - ratio / target) * 100)}% of the image height across the top and bottom.`
71
+ return `Cover mode hides about ${Math.round((1 - target / ratio) * 100)}% of the image width across the left and right edges.`
72
+ }
73
+
74
+ type CardParts = {
75
+ host: string
76
+ site: string
77
+ title: string
78
+ desc: string
79
+ cssImage: string
80
+ hasImage: boolean
81
+ missingText: string
82
+ alt: string
83
+ compact?: boolean
84
+ }
85
+
86
+ type Level = 'error' | 'warn' | 'info'
87
+
88
+ const ISSUE_ICONS: Record<Level, string> = {
89
+ error: `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.25" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="9"/><line x1="15" y1="9" x2="9" y2="15"/><line x1="9" y1="9" x2="15" y2="15"/></svg>`,
90
+ warn: `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.25" stroke-linecap="round" stroke-linejoin="round"><path d="M10.29 3.86 1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0z"/><line x1="12" y1="9" x2="12" y2="13"/><line x1="12" y1="17" x2="12.01" y2="17"/></svg>`,
91
+ info: `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.25" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="9"/><line x1="12" y1="16" x2="12" y2="12"/><line x1="12" y1="8" x2="12.01" y2="8"/></svg>`,
92
+ }
93
+
94
+ // Brand glyphs used purely to label each platform mock (24×24, currentColor).
95
+ const MARKS = {
96
+ fb: `<svg viewBox="0 0 24 24" fill="currentColor" aria-hidden="true"><path d="M24 12.07C24 5.41 18.63 0 12 0S0 5.4 0 12.07c0 6 4.39 10.97 10.13 11.87v-8.4H7.08v-3.47h3.05V9.43c0-3 1.79-4.67 4.53-4.67 1.31 0 2.68.24 2.68.24v2.95h-1.51c-1.49 0-1.96.93-1.96 1.87v2.25h3.33l-.53 3.47h-2.8v8.4C19.62 23.04 24 18.07 24 12.07z"/></svg>`,
97
+ x: `<svg viewBox="0 0 24 24" fill="currentColor" aria-hidden="true"><path d="M18.24 2.25h3.31l-7.23 8.26 8.5 11.24h-6.66l-5.21-6.82-5.97 6.82H1.68l7.73-8.84L1.25 2.25h6.83l4.71 6.23 5.45-6.23zm-1.16 17.52h1.83L7.08 4.13H5.12l11.96 15.64z"/></svg>`,
98
+ li: `<svg viewBox="0 0 24 24" fill="currentColor" aria-hidden="true"><path d="M20.45 20.45h-3.56v-5.57c0-1.33-.02-3.04-1.85-3.04-1.85 0-2.14 1.45-2.14 2.94v5.67H9.35V9h3.41v1.56h.05c.48-.9 1.64-1.85 3.37-1.85 3.6 0 4.27 2.37 4.27 5.45v6.29zM5.34 7.43a2.06 2.06 0 1 1 0-4.13 2.06 2.06 0 0 1 0 4.13zM7.12 20.45H3.55V9h3.57v11.45zM22.22 0H1.77C.79 0 0 .77 0 1.73v20.54C0 23.23.79 24 1.77 24h20.45c.98 0 1.78-.77 1.78-1.73V1.73C24 .77 23.2 0 22.22 0z"/></svg>`,
99
+ dc: `<svg viewBox="0 0 24 24" fill="currentColor" aria-hidden="true"><path d="M20.32 4.37A19.79 19.79 0 0 0 15.43 2.86a.07.07 0 0 0-.08.04c-.21.37-.44.86-.61 1.25a18.27 18.27 0 0 0-5.49 0 12.6 12.6 0 0 0-.62-1.25.08.08 0 0 0-.08-.04c-1.71.3-3.35.81-4.88 1.51a.07.07 0 0 0-.03.03C.53 9.05-.32 13.58.1 18.06a.08.08 0 0 0 .03.06 19.9 19.9 0 0 0 5.99 3.03.08.08 0 0 0 .09-.03c.46-.63.87-1.3 1.23-1.99a.08.08 0 0 0-.04-.11 13.1 13.1 0 0 1-1.87-.89.08.08 0 0 1-.01-.13l.37-.29a.07.07 0 0 1 .08-.01 14.2 14.2 0 0 0 12.06 0 .07.07 0 0 1 .08.01l.37.29a.08.08 0 0 1-.01.13c-.6.35-1.22.65-1.87.89a.08.08 0 0 0-.04.11c.36.69.78 1.36 1.23 1.99a.08.08 0 0 0 .08.03 19.84 19.84 0 0 0 6-3.03.08.08 0 0 0 .04-.06c.5-5.18-.84-9.67-3.55-13.66a.06.06 0 0 0-.03-.03zM8.02 15.33c-1.18 0-2.16-1.08-2.16-2.42s.95-2.42 2.16-2.42c1.21 0 2.18 1.1 2.16 2.42 0 1.34-.96 2.42-2.16 2.42zm7.97 0c-1.18 0-2.16-1.08-2.16-2.42s.96-2.42 2.16-2.42c1.21 0 2.18 1.1 2.16 2.42 0 1.34-.95 2.42-2.16 2.42z"/></svg>`,
100
+ } as const
101
+
102
+ export function renderHtml(report: Report): string {
103
+ const m = report.meta
104
+ const title = resolvePrimaryInput(m, 'title').value ?? ''
105
+ const pageHost = host(report.finalUrl)
106
+ const siteName = m.ogSiteName || pageHost
107
+ const ogProbe = m.ogImage ? report.image : undefined
108
+ const xProbe = m.twitterImage
109
+ ? (m.twitterImage === m.ogImage ? report.image : report.twitterImage ?? (!m.ogImage ? report.image : undefined))
110
+ : report.image
111
+ const ogCssImage = safeDataUri(ogProbe?.dataUri)
112
+ const xCssImage = safeDataUri(xProbe?.dataUri)
113
+
114
+ const baseCard = {
115
+ host: escapeHtml(pageHost),
116
+ site: escapeHtml(siteName),
117
+ }
118
+ const ogCard: CardParts = {
119
+ ...baseCard,
120
+ title: escapeHtml(resolvePlatformInput(m, 'Open Graph', 'title').value || '(no title)'),
121
+ desc: escapeHtml(resolvePlatformInput(m, 'Open Graph', 'description').value ?? ''),
122
+ cssImage: ogCssImage,
123
+ hasImage: ogCssImage !== '',
124
+ missingText: m.ogImage ? 'Image failed to load' : 'No og:image',
125
+ alt: escapeHtml(m.ogImageAlt ?? 'Share image preview'),
126
+ }
127
+ const xCard: CardParts = {
128
+ ...baseCard,
129
+ title: escapeHtml(resolvePlatformInput(m, 'X', 'title').value || '(no title)'),
130
+ desc: escapeHtml(resolvePlatformInput(m, 'X', 'description').value ?? ''),
131
+ cssImage: xCssImage,
132
+ hasImage: xCssImage !== '',
133
+ missingText: m.twitterImage || m.ogImage ? 'Image failed to load' : 'No card image',
134
+ alt: escapeHtml(m.twitterImageAlt ?? m.ogImageAlt ?? 'Share image preview'),
135
+ compact: m.twitterCard === CARD_SUMMARY,
136
+ }
137
+ const ogSources = sourceLabel([
138
+ m.ogTitle ? 'og:title' : m.title ? '<title> fallback' : undefined,
139
+ m.ogImage ? 'og:image' : 'no OG image',
140
+ ])
141
+ const xSources = sourceLabel([
142
+ m.twitterTitle ? 'twitter:title' : m.ogTitle ? 'OG title fallback' : '<title> fallback',
143
+ m.twitterImage ? 'twitter:image' : m.ogImage ? 'OG image fallback' : 'no image',
144
+ ])
145
+
146
+ const errorCount = report.issues.filter((i) => i.level === 'error').length
147
+ const warnCount = report.issues.filter((i) => i.level === 'warn').length
148
+ const infoCount = report.issues.filter((i) => i.level === 'info').length
149
+ const totalIssues = report.issues.length
150
+
151
+ const verdict = errorCount > 0
152
+ ? { kind: 'error', label: pluralize(errorCount, 'error') }
153
+ : warnCount > 0
154
+ ? { kind: 'warn', label: pluralize(warnCount, 'warning') }
155
+ : infoCount > 0
156
+ ? { kind: 'info', label: pluralize(infoCount, 'note') }
157
+ : { kind: 'ok', label: 'All clear' }
158
+
159
+ const dims = report.image?.width && report.image?.height
160
+ ? `${report.image.width} × ${report.image.height} px`
161
+ : report.image?.error
162
+ ? `Failed: ${report.image.error}`
163
+ : undefined
164
+ const ctype = report.image?.contentType?.split(';')[0]?.trim()
165
+ const detectedType = report.image?.detectedContentType
166
+ const bytes = report.image?.byteLength != null ? formatBytes(report.image.byteLength) : undefined
167
+ const ratio = report.image?.width && report.image.height
168
+ ? `${(report.image.width / report.image.height).toFixed(2)}:1`
169
+ : undefined
170
+ const crop = cropEvidence(report.image)
171
+ const resolvedInputs = resolveInputs(report)
172
+ const metaSnippet = buildMetaSnippet(report)
173
+ const agentPrompt = buildAgentPrompt(report)
174
+
175
+ const issuesHtml = report.issues
176
+ .map(
177
+ (i, index) => `
178
+ <li class="issue issue--${i.level}">
179
+ <span class="issue__icon" aria-hidden="true">${ISSUE_ICONS[i.level as Level]}</span>
180
+ <div class="issue__body">
181
+ <span class="issue__field">${String(index + 1).padStart(2, '0')} · ${escapeHtml(i.level)} · ${escapeHtml(i.field)}</span>
182
+ <p class="issue__msg">${escapeHtml(i.message)}</p>
183
+ <dl class="issue__details">
184
+ <div><dt>Impact</dt><dd>${escapeHtml(i.impact)}</dd></div>
185
+ <div><dt>Evidence</dt><dd>${escapeHtml(i.evidence)}</dd></div>
186
+ <div><dt>Fix</dt><dd>${escapeHtml(i.fix)}</dd></div>
187
+ </dl>
188
+ </div>
189
+ </li>`,
190
+ )
191
+ .join('')
192
+
193
+ const finalUrlEsc = escapeHtml(report.finalUrl)
194
+ const href = safeHttpHref(report.finalUrl)
195
+ const scriptNonce = crypto.randomUUID().replace(/-/g, '')
196
+
197
+ // Single source of truth for the parsed-meta facts — rendered into the panel and
198
+ // formatted into the "Copy" payload from the same list.
199
+ const facts: Fact[] = [
200
+ { key: 'source', value: report.source },
201
+ { key: 'final url', value: report.finalUrl },
202
+ { key: 'http', value: String(report.status) },
203
+ { key: 'og:title', value: m.ogTitle, count: count(m.ogTitle) },
204
+ { key: 'og:description', value: m.ogDescription, count: count(m.ogDescription) },
205
+ { key: 'og:image', value: m.ogImage },
206
+ { key: 'image', value: dims },
207
+ { key: 'ratio', value: ratio },
208
+ { key: 'type', value: ctype },
209
+ { key: 'detected type', value: detectedType },
210
+ { key: 'bytes', value: bytes },
211
+ { key: 'twitter:card', value: m.twitterCard },
212
+ { key: 'og:site_name', value: m.ogSiteName },
213
+ { key: 'canonical', value: m.canonical ?? m.ogUrl },
214
+ ]
215
+ const copyPayloads = buildCopyPayloads(report, facts)
216
+
217
+ return `<!doctype html>
218
+ <html lang="en">
219
+ <head>
220
+ <meta charset="utf-8" />
221
+ <meta name="viewport" content="width=device-width, initial-scale=1" />
222
+ <meta name="robots" content="noindex" />
223
+ <meta name="color-scheme" content="light" />
224
+ <meta http-equiv="Content-Security-Policy" content="default-src 'none'; img-src data:; style-src 'unsafe-inline'; script-src 'nonce-${scriptNonce}'; connect-src 'none'; base-uri 'none'; form-action 'none'" />
225
+ <title>metaprev · ${escapeHtml(pageHost || title || 'preview')}</title>
226
+ <style>
227
+ *, *::before, *::after { box-sizing: border-box; }
228
+ html { -webkit-text-size-adjust: 100%; }
229
+ html, body { margin: 0; padding: 0; }
230
+ html, body { overflow-x: clip; }
231
+
232
+ :root {
233
+ /* Warm paper workbench, OKLCH, tinted toward 75° so no neutral is a dead gray. */
234
+ --paper: oklch(97.6% 0.006 75);
235
+ --surface: oklch(99.3% 0.004 80);
236
+ --stage: oklch(94.4% 0.008 75);
237
+ --stage-dark: oklch(26% 0.012 264);
238
+ --ink: oklch(26% 0.012 65);
239
+ --ink-2: oklch(46% 0.012 65);
240
+ --ink-3: oklch(53% 0.01 65);
241
+ --line: oklch(89% 0.008 75);
242
+ --line-2: oklch(93.5% 0.006 75);
243
+
244
+ --accent: oklch(57% 0.165 41);
245
+ --accent-2: oklch(48% 0.155 39);
246
+ --accent-wash: oklch(95.5% 0.03 50);
247
+
248
+ --error: oklch(52% 0.19 27);
249
+ --error-wash: oklch(96% 0.035 27);
250
+ --error-line: oklch(86% 0.07 27);
251
+ --warn: oklch(52% 0.11 64);
252
+ --warn-wash: oklch(96.5% 0.05 80);
253
+ --warn-line: oklch(86% 0.08 80);
254
+ --info: oklch(52% 0.12 255);
255
+ --info-wash: oklch(96.5% 0.025 255);
256
+ --info-line: oklch(87% 0.05 255);
257
+ --ok: oklch(50% 0.13 152);
258
+ --ok-wash: oklch(96% 0.04 152);
259
+ --ok-line: oklch(85% 0.08 152);
260
+
261
+ --sans: ui-sans-serif, -apple-system, BlinkMacSystemFont, 'Segoe UI', system-ui, 'Helvetica Neue', sans-serif;
262
+ --mono: ui-monospace, 'SF Mono', 'JetBrains Mono', 'Cascadia Code', Menlo, Consolas, monospace;
263
+
264
+ --r-sm: 8px; --r-md: 12px; --r-lg: 18px;
265
+ --shadow-card: 0 1px 2px oklch(26% 0.012 65 / 0.05), 0 6px 20px oklch(26% 0.012 65 / 0.06);
266
+ --shadow-pop: 0 2px 6px oklch(26% 0.012 65 / 0.08), 0 14px 40px oklch(26% 0.012 65 / 0.10);
267
+ --ease: cubic-bezier(0.22, 1, 0.36, 1);
268
+ }
269
+
270
+ body {
271
+ background:
272
+ radial-gradient(110% 60% at 50% -8%, var(--accent-wash) 0%, transparent 60%),
273
+ var(--paper);
274
+ background-attachment: fixed;
275
+ color: var(--ink);
276
+ font-family: var(--sans);
277
+ font-size: 14px;
278
+ line-height: 1.5;
279
+ -webkit-font-smoothing: antialiased;
280
+ -moz-osx-font-smoothing: grayscale;
281
+ min-height: 100vh;
282
+ display: flex;
283
+ flex-direction: column;
284
+ }
285
+
286
+ .wrap { width: 100%; max-width: 1120px; margin: 0 auto; padding-inline: 28px; }
287
+ @media (max-width: 600px) { .wrap { padding-inline: 16px; } }
288
+
289
+ h1, h2, h3 { margin: 0; font-weight: 600; letter-spacing: -0.01em; }
290
+ a { color: inherit; }
291
+ .sr-only {
292
+ position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px;
293
+ overflow: hidden; clip: rect(0 0 0 0); white-space: nowrap; border: 0;
294
+ }
295
+ :focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; border-radius: 4px; }
296
+
297
+ /* ── Top bar ── */
298
+ .topbar {
299
+ position: sticky; top: 0; z-index: 20;
300
+ background: oklch(97.6% 0.006 75 / 0.82);
301
+ backdrop-filter: saturate(1.4) blur(10px);
302
+ -webkit-backdrop-filter: saturate(1.4) blur(10px);
303
+ border-bottom: 1px solid var(--line);
304
+ }
305
+ .topbar__inner {
306
+ display: flex; align-items: center; gap: 16px;
307
+ padding-block: 14px; min-height: 60px;
308
+ }
309
+ .brand {
310
+ display: inline-flex; align-items: center; gap: 8px;
311
+ font-family: var(--mono); font-size: 13px; font-weight: 600;
312
+ letter-spacing: -0.02em; color: var(--ink); flex-shrink: 0; white-space: nowrap;
313
+ }
314
+ .brand__dot {
315
+ width: 9px; height: 9px; border-radius: 3px; background: var(--accent);
316
+ box-shadow: 0 0 0 3px var(--accent-wash);
317
+ }
318
+ .target {
319
+ flex: 1; min-width: 0;
320
+ font-family: var(--mono); font-size: 13px; color: var(--ink-2);
321
+ white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
322
+ text-decoration: none; padding: 4px 10px; border-radius: var(--r-sm);
323
+ border: 1px solid transparent; transition: border-color 0.18s var(--ease), color 0.18s var(--ease);
324
+ }
325
+ .target:hover { color: var(--ink); border-color: var(--line); }
326
+ .verdict {
327
+ display: inline-flex; align-items: center; gap: 7px; flex-shrink: 0;
328
+ padding: 6px 13px; border-radius: 999px; font-size: 12.5px; font-weight: 600;
329
+ border: 1px solid transparent; white-space: nowrap;
330
+ }
331
+ .verdict__dot { width: 7px; height: 7px; border-radius: 50%; background: currentColor; }
332
+ .verdict--ok { color: var(--ok); background: var(--ok-wash); border-color: var(--ok-line); }
333
+ .verdict--error { color: var(--error); background: var(--error-wash); border-color: var(--error-line); }
334
+ .verdict--warn { color: var(--warn); background: var(--warn-wash); border-color: var(--warn-line); }
335
+ .verdict--info { color: var(--info); background: var(--info-wash); border-color: var(--info-line); }
336
+ .verdict--ok .verdict__dot { animation: pulse 2s var(--ease) infinite; }
337
+ @keyframes pulse { 0%, 100% { opacity: 1; } 50% { opacity: 0.45; } }
338
+
339
+ /* ── Summary strip ── */
340
+ .summary { padding-top: 30px; }
341
+ .summary__title {
342
+ font-size: clamp(22px, 4vw, 30px); line-height: 1.1; letter-spacing: -0.025em;
343
+ color: var(--ink); max-width: 24ch;
344
+ }
345
+ .summary__title b { color: var(--accent-2); font-weight: 600; }
346
+ .summary__lede { max-width: 68ch; margin: 10px 0 0; color: var(--ink-2); font-size: 13.5px; }
347
+ .summary__meta {
348
+ margin-top: 14px; display: flex; flex-wrap: wrap; gap: 8px 10px;
349
+ font-family: var(--mono); font-size: 12px; color: var(--ink-2);
350
+ }
351
+ .chip {
352
+ display: inline-flex; align-items: center; gap: 6px;
353
+ padding: 4px 10px; border-radius: 999px; background: var(--surface);
354
+ border: 1px solid var(--line); font-variant-numeric: tabular-nums; white-space: nowrap;
355
+ }
356
+ .chip svg { width: 13px; height: 13px; opacity: 0.7; }
357
+ .chip--error { color: var(--error); border-color: var(--error-line); background: var(--error-wash); }
358
+ .chip--warn { color: var(--warn); border-color: var(--warn-line); background: var(--warn-wash); }
359
+ .chip--info { color: var(--info); border-color: var(--info-line); background: var(--info-wash); }
360
+ .chip--ok { color: var(--ok); border-color: var(--ok-line); background: var(--ok-wash); }
361
+ .chip--muted { color: var(--ink-3); }
362
+
363
+ main { flex: 1; padding-bottom: 56px; }
364
+
365
+ /* ── Section heads ── */
366
+ .section { margin-top: 40px; }
367
+ .section__head {
368
+ display: flex; align-items: baseline; justify-content: space-between; gap: 16px;
369
+ margin-bottom: 18px; flex-wrap: wrap;
370
+ }
371
+ .section__label {
372
+ font-family: var(--mono); font-size: 11px; font-weight: 600;
373
+ text-transform: uppercase; letter-spacing: 0.14em; color: var(--ink-3);
374
+ }
375
+ .section__support { margin: 5px 0 0; max-width: 68ch; color: var(--ink-2); font-size: 12.5px; }
376
+
377
+ /* ── Appearance toggle ── */
378
+ .seg {
379
+ display: inline-flex; padding: 3px; gap: 2px; border-radius: 999px;
380
+ background: var(--stage); border: 1px solid var(--line);
381
+ }
382
+ .seg__btn {
383
+ font: inherit; font-size: 12px; font-weight: 600; cursor: pointer;
384
+ color: var(--ink-2); background: transparent; border: 0; border-radius: 999px;
385
+ padding: 5px 14px; display: inline-flex; align-items: center; gap: 6px;
386
+ transition: color 0.18s var(--ease);
387
+ }
388
+ .seg__btn svg { width: 13px; height: 13px; }
389
+ .seg__btn[aria-pressed="true"] {
390
+ color: var(--ink); background: var(--surface); box-shadow: var(--shadow-card);
391
+ }
392
+
393
+ /* ── Card stage ── */
394
+ .stage {
395
+ border-radius: var(--r-lg); padding: 26px;
396
+ background: var(--stage);
397
+ border: 1px solid var(--line);
398
+ transition: background 0.35s var(--ease), border-color 0.35s var(--ease);
399
+ }
400
+ .stage[data-appearance="dark"] { background: var(--stage-dark); border-color: oklch(34% 0.02 264); }
401
+ .grid {
402
+ display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 22px;
403
+ }
404
+ @media (max-width: 760px) { .grid { grid-template-columns: minmax(0, 1fr); } }
405
+
406
+ .card { min-width: 0; }
407
+ .card__head {
408
+ display: flex; align-items: center; gap: 8px; margin-bottom: 10px;
409
+ color: var(--ink-2);
410
+ }
411
+ .stage[data-appearance="dark"] .card__head { color: oklch(78% 0.01 264); }
412
+ .card__mark { width: 16px; height: 16px; flex-shrink: 0; }
413
+ .card__name { font-size: 12.5px; font-weight: 600; letter-spacing: -0.01em; }
414
+ .card__note {
415
+ margin-left: auto; font-family: var(--mono); font-size: 10.5px;
416
+ letter-spacing: 0.02em; color: var(--ink-3);
417
+ }
418
+ .stage[data-appearance="dark"] .card__note { color: oklch(58% 0.01 264); }
419
+
420
+ /* shared mock image */
421
+ .mock__img {
422
+ background-color: oklch(90% 0.01 75);
423
+ background-size: cover; background-position: center; background-repeat: no-repeat;
424
+ }
425
+ .mock__img--missing {
426
+ display: flex; align-items: center; justify-content: center;
427
+ background: repeating-linear-gradient(45deg, oklch(91% 0.01 75) 0 10px, oklch(93% 0.008 75) 10px 20px);
428
+ color: var(--ink-3); font-family: var(--mono); font-size: 11px;
429
+ letter-spacing: 0.06em; text-transform: uppercase;
430
+ }
431
+ .mock__line-clamp { display: -webkit-box; -webkit-box-orient: vertical; overflow: hidden; }
432
+
433
+ /* ── Facebook ── */
434
+ .mock--fb {
435
+ border-radius: 8px; overflow: hidden; border: 1px solid #dadde1; background: #fff;
436
+ font-family: Helvetica, Arial, sans-serif;
437
+ }
438
+ .mock--fb .mock__img { aspect-ratio: 1.91/1; border-bottom: 1px solid #dadde1; }
439
+ .mock--fb .mock__img--missing { aspect-ratio: 1.91/1; }
440
+ .mock--fb .mock__body { background: #f2f3f5; padding: 10px 12px; }
441
+ .mock--fb .mock__site { font-size: 12px; text-transform: uppercase; color: #606770; letter-spacing: 0.2px; }
442
+ .mock--fb .mock__title { font-size: 16px; font-weight: 600; color: #050505; margin: 3px 0 0; line-height: 1.27; -webkit-line-clamp: 2; }
443
+ .mock--fb .mock__desc { font-size: 13px; color: #606770; margin: 3px 0 0; line-height: 1.3; -webkit-line-clamp: 1; }
444
+ [data-appearance="dark"] .mock--fb { background: #242526; border-color: #393a3b; }
445
+ [data-appearance="dark"] .mock--fb .mock__img { border-bottom-color: #393a3b; }
446
+ [data-appearance="dark"] .mock--fb .mock__body { background: #3a3b3c; }
447
+ [data-appearance="dark"] .mock--fb .mock__site { color: #b0b3b8; }
448
+ [data-appearance="dark"] .mock--fb .mock__title { color: #e4e6eb; }
449
+ [data-appearance="dark"] .mock--fb .mock__desc { color: #b0b3b8; }
450
+
451
+ /* ── X (summary_large_image): image + domain overlay only, no text below ── */
452
+ .mock--x .mock__shot { position: relative; border-radius: 16px; overflow: hidden; border: 1px solid #cfd9de; }
453
+ .mock--x .mock__img { aspect-ratio: 1.91/1; }
454
+ .mock--x .mock__img--missing { aspect-ratio: 1.91/1; }
455
+ .mock--x .mock__domain {
456
+ position: absolute; left: 12px; bottom: 12px;
457
+ background: rgba(0,0,0,0.65); color: #fff; font-family: system-ui, sans-serif;
458
+ font-size: 12.5px; padding: 1px 7px; border-radius: 4px; max-width: calc(100% - 24px);
459
+ overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
460
+ }
461
+ [data-appearance="dark"] .mock--x .mock__shot { border-color: #2f3336; }
462
+ /* X no-image fallback = compact summary card */
463
+ .mock--x .mock__summary {
464
+ border: 1px solid #cfd9de; border-radius: 16px; overflow: hidden;
465
+ font-family: system-ui, sans-serif; background: #fff;
466
+ }
467
+ .mock--x .mock__summary .mock__body { padding: 12px 14px; }
468
+ .mock--x .mock__summary--with-image { display: grid; grid-template-columns: minmax(0, 1fr) 112px; }
469
+ .mock--x .mock__summary--with-image .mock__thumb {
470
+ min-height: 112px; border-left: 1px solid #cfd9de;
471
+ background-color: #eff3f4; background-size: cover; background-position: center;
472
+ }
473
+ .mock--x .mock__summary .mock__site { font-size: 13px; color: #536471; }
474
+ .mock--x .mock__summary .mock__title { font-size: 15px; font-weight: 700; color: #0f1419; margin: 2px 0 0; -webkit-line-clamp: 2; line-height: 1.3; }
475
+ .mock--x .mock__summary .mock__desc { font-size: 14px; color: #536471; margin: 2px 0 0; -webkit-line-clamp: 2; line-height: 1.3; }
476
+ [data-appearance="dark"] .mock--x .mock__summary { background: #16181c; border-color: #2f3336; }
477
+ [data-appearance="dark"] .mock--x .mock__summary--with-image .mock__thumb { border-left-color: #2f3336; }
478
+ [data-appearance="dark"] .mock--x .mock__summary .mock__title { color: #e7e9ea; }
479
+ [data-appearance="dark"] .mock--x .mock__summary .mock__site,
480
+ [data-appearance="dark"] .mock--x .mock__summary .mock__desc { color: #71767b; }
481
+
482
+ /* ── LinkedIn: image + heavy title + domain, no description ── */
483
+ .mock--li { border-radius: 8px; overflow: hidden; border: 1px solid #e0e0e0; background: #fff; font-family: -apple-system, system-ui, 'Segoe UI', sans-serif; }
484
+ .mock--li .mock__img { aspect-ratio: 1.91/1; }
485
+ .mock--li .mock__img--missing { aspect-ratio: 1.91/1; }
486
+ .mock--li .mock__body { padding: 10px 12px; background: #fff; }
487
+ .mock--li .mock__title { font-size: 14px; font-weight: 600; color: rgba(0,0,0,0.9); line-height: 1.29; -webkit-line-clamp: 2; }
488
+ .mock--li .mock__site { font-size: 12px; color: rgba(0,0,0,0.6); margin-top: 4px; }
489
+ [data-appearance="dark"] .mock--li { background: #1b1f23; border-color: #38434f; }
490
+ [data-appearance="dark"] .mock--li .mock__body { background: #1b1f23; }
491
+ [data-appearance="dark"] .mock--li .mock__title { color: rgba(255,255,255,0.9); }
492
+ [data-appearance="dark"] .mock--li .mock__site { color: rgba(255,255,255,0.6); }
493
+
494
+ /* ── Discord auto-embed (no color bar on OG unfurls) ── */
495
+ .mock--dc { border-radius: 8px; overflow: hidden; background: #2b2d31; border: 1px solid #1e1f22; font-family: 'gg sans', system-ui, sans-serif; }
496
+ .mock--dc .mock__body { padding: 12px 14px 8px; }
497
+ .mock--dc .mock__site { font-size: 12px; color: #b5bac1; }
498
+ .mock--dc .mock__title { font-size: 15px; font-weight: 600; color: #00a8fc; margin: 4px 0 0; line-height: 1.27; -webkit-line-clamp: 2; }
499
+ .mock--dc .mock__desc { font-size: 13px; color: #dbdee1; margin: 5px 0 0; line-height: 1.38; -webkit-line-clamp: 3; }
500
+ .mock--dc .mock__img { aspect-ratio: 1.91/1; margin: 12px 14px 14px; border-radius: 6px; max-width: 380px; }
501
+ .mock--dc .mock__img--missing { aspect-ratio: 1.91/1; margin: 12px 14px 14px; border-radius: 6px; max-width: 380px; background: repeating-linear-gradient(45deg, #232529 0 10px, #2b2d31 10px 20px); color: #72767d; }
502
+ [data-appearance="light"] .mock--dc { background: #ffffff; border-color: #e3e5e8; }
503
+ [data-appearance="light"] .mock--dc .mock__site { color: #5c5e66; }
504
+ [data-appearance="light"] .mock--dc .mock__title { color: #0067e0; }
505
+ [data-appearance="light"] .mock--dc .mock__desc { color: #4e5058; }
506
+ [data-appearance="light"] .mock--dc .mock__img--missing { background: repeating-linear-gradient(45deg, #e8eaed 0 10px, #f1f2f4 10px 20px); color: #8a8d93; }
507
+
508
+ /* ── Detail panels ── */
509
+ .panels { display: grid; grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); gap: 22px; }
510
+ @media (max-width: 760px) { .panels { grid-template-columns: minmax(0, 1fr); } }
511
+ .panel {
512
+ background: var(--surface); border: 1px solid var(--line);
513
+ border-radius: var(--r-md); box-shadow: var(--shadow-card); overflow: hidden;
514
+ }
515
+ .panel__head {
516
+ display: flex; align-items: center; justify-content: space-between; gap: 10px;
517
+ padding: 14px 16px; border-bottom: 1px solid var(--line-2);
518
+ }
519
+ .panel__title { font-size: 13px; font-weight: 600; display: flex; align-items: center; gap: 8px; }
520
+ .panel__count {
521
+ font-family: var(--mono); font-size: 11px; color: var(--ink-3);
522
+ background: var(--stage); padding: 2px 7px; border-radius: 999px; font-weight: 600;
523
+ }
524
+ .panel__body { padding: 14px 16px; }
525
+
526
+ /* neutral image inspection: show the deterministic cover crop beside the whole asset */
527
+ .asset-grid { display: grid; grid-template-columns: minmax(0, 1.45fr) minmax(260px, 0.55fr); gap: 22px; align-items: start; }
528
+ .asset-views { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 12px; }
529
+ .asset-view { margin: 0; min-width: 0; }
530
+ .asset-frame {
531
+ aspect-ratio: 1.91 / 1; border-radius: var(--r-sm); border: 1px solid var(--line);
532
+ background-color: var(--stage); background-position: center; background-repeat: no-repeat;
533
+ overflow: hidden; display: grid; place-items: center;
534
+ }
535
+ .asset-frame--cover { background-size: cover; }
536
+ .asset-frame--fit { background-size: contain; }
537
+ .asset-frame--empty { color: var(--ink-3); font: 11px var(--mono); text-transform: uppercase; letter-spacing: .05em; }
538
+ .asset-view figcaption { margin-top: 7px; color: var(--ink-2); font-size: 11.5px; }
539
+ .asset-view figcaption b { display: block; color: var(--ink); font: 600 11px var(--mono); }
540
+ .asset-readout { margin: 0; display: grid; gap: 0; }
541
+ .asset-readout div { padding: 9px 0; border-bottom: 1px solid var(--line-2); }
542
+ .asset-readout div:first-child { padding-top: 0; }
543
+ .asset-readout div:last-child { border: 0; }
544
+ .asset-readout dt { color: var(--ink-3); font: 600 10.5px var(--mono); text-transform: uppercase; letter-spacing: .06em; }
545
+ .asset-readout dd { margin: 3px 0 0; color: var(--ink); font-size: 12.5px; overflow-wrap: anywhere; }
546
+ @media (max-width: 760px) { .asset-grid { grid-template-columns: 1fr; } }
547
+ @media (max-width: 520px) { .asset-views { grid-template-columns: 1fr; } }
548
+
549
+ .copy-btn {
550
+ font: inherit; font-family: var(--mono); font-size: 11px; font-weight: 600;
551
+ color: var(--ink-2); background: transparent; border: 1px solid var(--line);
552
+ border-radius: var(--r-sm); padding: 4px 10px; cursor: pointer;
553
+ display: inline-flex; align-items: center; gap: 5px;
554
+ transition: color 0.18s var(--ease), border-color 0.18s var(--ease), background 0.18s var(--ease);
555
+ }
556
+ .copy-btn svg { width: 12px; height: 12px; }
557
+ .copy-btn:hover { color: var(--ink); border-color: var(--ink-3); }
558
+ .copy-btn[data-state="copied"] { color: var(--ok); border-color: var(--ok-line); background: var(--ok-wash); }
559
+
560
+ /* issues */
561
+ .issues { list-style: none; margin: 0; padding: 0; display: grid; gap: 9px; }
562
+ .issue {
563
+ display: grid; grid-template-columns: 22px 1fr; gap: 11px; align-items: start;
564
+ padding: 11px 13px; border-radius: var(--r-sm); border: 1px solid;
565
+ }
566
+ .issue--error { background: var(--error-wash); border-color: var(--error-line); }
567
+ .issue--warn { background: var(--warn-wash); border-color: var(--warn-line); }
568
+ .issue--info { background: var(--info-wash); border-color: var(--info-line); }
569
+ .issue__icon { width: 20px; height: 20px; margin-top: 1px; }
570
+ .issue__icon svg { width: 20px; height: 20px; }
571
+ .issue--error .issue__icon { color: var(--error); }
572
+ .issue--warn .issue__icon { color: var(--warn); }
573
+ .issue--info .issue__icon { color: var(--info); }
574
+ .issue__body { min-width: 0; }
575
+ .issue__field { font-family: var(--mono); font-size: 11px; font-weight: 600; letter-spacing: 0.02em; }
576
+ .issue--error .issue__field { color: var(--error); }
577
+ .issue--warn .issue__field { color: var(--warn); }
578
+ .issue--info .issue__field { color: var(--info); }
579
+ .issue__msg { margin: 2px 0 0; font-size: 13px; line-height: 1.42; color: var(--ink); }
580
+ .issue__details { margin: 8px 0 0; display: grid; gap: 5px; }
581
+ .issue__details div { display: grid; grid-template-columns: 64px minmax(0, 1fr); gap: 8px; }
582
+ .issue__details dt { font: 600 10px var(--mono); text-transform: uppercase; letter-spacing: .05em; color: var(--ink-3); }
583
+ .issue__details dd { margin: 0; color: var(--ink-2); font-size: 12px; line-height: 1.42; }
584
+
585
+ .clean { display: flex; flex-direction: column; align-items: center; text-align: center; gap: 10px; padding: 26px 12px; color: var(--ok); }
586
+ .clean svg { width: 30px; height: 30px; }
587
+ .clean p { margin: 0; font-size: 13.5px; font-weight: 600; color: var(--ink); }
588
+ .clean span { font-size: 12.5px; color: var(--ink-2); font-weight: 400; }
589
+
590
+ /* facts */
591
+ .facts { margin: 0; }
592
+ .fact {
593
+ display: grid; grid-template-columns: 116px minmax(0, 1fr); gap: 14px;
594
+ padding: 9px 0; border-bottom: 1px solid var(--line-2); align-items: baseline;
595
+ }
596
+ .fact:last-child { border-bottom: 0; }
597
+ .fact:first-child { padding-top: 0; }
598
+ .fact__key { font-family: var(--mono); font-size: 11px; color: var(--ink-3); font-weight: 600; }
599
+ .fact__val { font-size: 13px; color: var(--ink); word-break: break-word; overflow-wrap: anywhere; min-width: 0; }
600
+ .fact__val.is-empty { color: var(--ink-3); font-style: italic; }
601
+ .fact__count { font-family: var(--mono); font-size: 11px; color: var(--ink-3); font-variant-numeric: tabular-nums; }
602
+ @media (max-width: 480px) { .fact { grid-template-columns: 92px minmax(0, 1fr); gap: 10px; } }
603
+
604
+ .subhead { margin: 18px 0 8px; padding-top: 16px; border-top: 1px solid var(--line-2); font: 600 10.5px var(--mono); color: var(--ink-3); text-transform: uppercase; letter-spacing: .08em; }
605
+ .resolved { display: grid; gap: 7px; }
606
+ .resolved__row { display: grid; grid-template-columns: 84px 78px minmax(0, 1fr); gap: 8px; align-items: baseline; font-size: 11.5px; }
607
+ .resolved__platform, .resolved__field { font-family: var(--mono); color: var(--ink-3); }
608
+ .resolved__source { min-width: 0; overflow-wrap: anywhere; color: var(--ink); }
609
+ .resolved__source b { color: var(--accent-2); font-weight: 600; }
610
+ @media (max-width: 480px) { .resolved__row { grid-template-columns: 72px 64px minmax(0, 1fr); } }
611
+
612
+ .repair { display: grid; gap: 18px; }
613
+ .repair__head { display: grid; grid-template-columns: minmax(0, 1fr) auto; gap: 20px; align-items: center; }
614
+ .repair__title { margin: 0; font-size: 14px; }
615
+ .repair__copy { margin: 5px 0 0; color: var(--ink-2); font-size: 12.5px; max-width: 70ch; }
616
+ .repair__actions { display: flex; flex-wrap: wrap; justify-content: flex-end; gap: 8px; }
617
+ .repair__outputs { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 12px; }
618
+ .repair__output { min-width: 0; border: 1px solid var(--line); border-radius: var(--r-sm); background: var(--paper); overflow: hidden; }
619
+ .repair__output summary { cursor: pointer; padding: 10px 12px; font: 600 11px var(--mono); color: var(--ink-2); }
620
+ .repair__output[open] summary { border-bottom: 1px solid var(--line); }
621
+ .repair__output pre { margin: 0; padding: 12px; max-height: 320px; overflow: auto; white-space: pre-wrap; overflow-wrap: anywhere; font: 11px/1.55 var(--mono); color: var(--ink); }
622
+ .copy-btn--primary { color: var(--surface); background: var(--accent-2); border-color: var(--accent-2); }
623
+ .copy-btn--primary:hover { color: white; background: var(--accent); border-color: var(--accent); }
624
+ @media (max-width: 700px) { .repair__head, .repair__outputs { grid-template-columns: 1fr; } .repair__actions { justify-content: flex-start; } }
625
+
626
+ /* footer */
627
+ .footer { border-top: 1px solid var(--line); }
628
+ .footer__inner {
629
+ display: flex; align-items: center; justify-content: space-between; gap: 14px; flex-wrap: wrap;
630
+ padding-block: 22px; font-family: var(--mono); font-size: 11.5px; color: var(--ink-3);
631
+ }
632
+ .footer__brand { display: inline-flex; align-items: center; gap: 6px; }
633
+ .footer__brand b { color: var(--ink-2); font-weight: 600; }
634
+
635
+ @media (prefers-reduced-motion: reduce) {
636
+ *, *::before, *::after { animation-duration: 0.001ms !important; animation-iteration-count: 1 !important; transition-duration: 0.001ms !important; }
637
+ }
638
+ </style>
639
+ </head>
640
+ <body>
641
+ <header class="topbar">
642
+ <div class="wrap topbar__inner">
643
+ <span class="brand"><span class="brand__dot" aria-hidden="true"></span>metaprev</span>
644
+ <a class="target" href="${href}" target="_blank" rel="noopener" title="${finalUrlEsc}">${finalUrlEsc}</a>
645
+ <span class="verdict verdict--${verdict.kind}">
646
+ <span class="verdict__dot" aria-hidden="true"></span>${escapeHtml(verdict.label)}
647
+ </span>
648
+ </div>
649
+ </header>
650
+
651
+ <main>
652
+ <section class="wrap summary rise">
653
+ <h1 class="summary__title">Share preview for <b>${escapeHtml(pageHost || 'your link')}</b></h1>
654
+ <p class="summary__lede">Representative previews built from the metadata and image fetched in this run. Platform UI, experiments, and cached unfurls can differ; the source labels below show every fallback metaprev used.</p>
655
+ <div class="summary__meta">
656
+ <span class="chip chip--muted">HTTP ${escapeHtml(String(report.status))}</span>
657
+ ${dims ? `<span class="chip chip--muted">${escapeHtml(dims)}</span>` : ''}
658
+ ${bytes ? `<span class="chip chip--muted">${escapeHtml(bytes)}</span>` : ''}
659
+ ${errorCount ? `<span class="chip chip--error">${ISSUE_ICONS.error}${pluralize(errorCount, 'error')}</span>` : ''}
660
+ ${warnCount ? `<span class="chip chip--warn">${ISSUE_ICONS.warn}${pluralize(warnCount, 'warning')}</span>` : ''}
661
+ ${infoCount ? `<span class="chip chip--info">${ISSUE_ICONS.info}${pluralize(infoCount, 'note')}</span>` : ''}
662
+ ${totalIssues === 0 ? `<span class="chip chip--ok">No issues</span>` : ''}
663
+ </div>
664
+ </section>
665
+
666
+ <section class="wrap section">
667
+ <div class="section__head">
668
+ <div>
669
+ <h2 class="section__label">Platform workspace</h2>
670
+ <p class="section__support">Compare the fields each card consumes. X prefers twitter:* values; the other previews use Open Graph. Slack classic unfurls also inspect common Open Graph and X metadata, but their UI is not represented by the Discord card.</p>
671
+ </div>
672
+ <div class="seg" role="group" aria-label="Preview appearance">
673
+ <button type="button" class="seg__btn" data-appearance-set="light" aria-pressed="true">
674
+ <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><circle cx="12" cy="12" r="4.5"/><path d="M12 2v2M12 20v2M4.2 4.2l1.4 1.4M18.4 18.4l1.4 1.4M2 12h2M20 12h2M4.2 19.8l1.4-1.4M18.4 5.6l1.4-1.4"/></svg>Light
675
+ </button>
676
+ <button type="button" class="seg__btn" data-appearance-set="dark" aria-pressed="false">
677
+ <svg viewBox="0 0 24 24" fill="currentColor" stroke="none"><path d="M21 12.8A9 9 0 1 1 11.2 3a7 7 0 0 0 9.8 9.8z"/></svg>Dark
678
+ </button>
679
+ </div>
680
+ </div>
681
+ <div class="stage rise" id="stage" data-appearance="light" style="animation-delay:0.06s">
682
+ <div class="grid">
683
+ ${cardMock('Facebook', 'fb', ogCard, ogSources)}
684
+ ${cardMock('X', 'x', xCard, xSources)}
685
+ ${cardMock('LinkedIn', 'li', ogCard, ogSources)}
686
+ ${cardMock('Discord', 'dc', ogCard, ogSources)}
687
+ </div>
688
+ </div>
689
+ </section>
690
+
691
+ <section class="wrap section" aria-labelledby="asset-title">
692
+ <div class="section__head">
693
+ <div>
694
+ <h2 class="section__label" id="asset-title">Image inspection</h2>
695
+ <p class="section__support">Cover shows the deterministic 1.91:1 crop used by the mocks. Fit keeps the whole asset visible, so edge loss and padding are easy to compare.</p>
696
+ </div>
697
+ </div>
698
+ <div class="panel rise" style="animation-delay:0.1s">
699
+ <div class="panel__body asset-grid">
700
+ <div class="asset-views">
701
+ <figure class="asset-view">
702
+ <div class="asset-frame asset-frame--cover${ogCssImage ? '' : ' asset-frame--empty'}"${ogCssImage ? ` style="background-image:url('${ogCssImage}')" role="img" aria-label="${escapeHtml(m.ogImageAlt ?? 'Open Graph image shown with a centered cover crop')}"` : ''}>${ogCssImage ? '' : 'No validated OG image'}</div>
703
+ <figcaption><b>Cover crop</b>Fills a 1.91:1 card frame.</figcaption>
704
+ </figure>
705
+ <figure class="asset-view">
706
+ <div class="asset-frame asset-frame--fit${ogCssImage ? '' : ' asset-frame--empty'}"${ogCssImage ? ` style="background-image:url('${ogCssImage}')" aria-hidden="true"` : ''}>${ogCssImage ? '' : 'No validated OG image'}</div>
707
+ <figcaption><b>Whole asset</b>Fits inside the same frame.</figcaption>
708
+ </figure>
709
+ </div>
710
+ <dl class="asset-readout">
711
+ <div><dt>Decoded size</dt><dd>${escapeHtml(dims ?? 'Unknown')}</dd></div>
712
+ <div><dt>Aspect ratio</dt><dd>${escapeHtml(ratio ?? 'Unknown')} · target 1.91:1</dd></div>
713
+ <div><dt>Cover result</dt><dd>${escapeHtml(crop)}</dd></div>
714
+ <div><dt>Response</dt><dd>${escapeHtml([ctype, bytes].filter(Boolean).join(' · ') || 'Unknown')}</dd></div>
715
+ ${detectedType && detectedType !== ctype ? `<div><dt>Detected bytes</dt><dd>${escapeHtml(detectedType)}</dd></div>` : ''}
716
+ <div><dt>OG source</dt><dd>${escapeHtml(m.ogImage ?? 'No og:image')}</dd></div>
717
+ ${m.twitterImage && m.twitterImage !== m.ogImage ? `<div><dt>X override</dt><dd>${escapeHtml(m.twitterImage)}</dd></div>` : ''}
718
+ </dl>
719
+ </div>
720
+ </div>
721
+ </section>
722
+
723
+ <section class="wrap section">
724
+ <div class="panels">
725
+ <div class="panel rise" style="animation-delay:0.12s">
726
+ <div class="panel__head">
727
+ <span class="panel__title">
728
+ <svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="color:var(--ink-3)"><path d="m9 11 3 3L22 4"/><path d="M21 12v7a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11"/></svg>
729
+ Validation
730
+ ${totalIssues > 0 ? `<span class="panel__count">${totalIssues}</span>` : ''}
731
+ </span>
732
+ ${totalIssues > 0 ? copyButton('issues', 'Copy findings') : ''}
733
+ </div>
734
+ <div class="panel__body">
735
+ ${totalIssues === 0
736
+ ? `<div class="clean">
737
+ <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M22 11.08V12a10 10 0 1 1-5.93-9.14"/><polyline points="22 4 12 14.01 9 11.01"/></svg>
738
+ <p>No validation issues found</p>
739
+ <span>Review the visual crop and source fallbacks before shipping.</span>
740
+ </div>`
741
+ : `<ul class="issues">${issuesHtml}</ul>`}
742
+ </div>
743
+ </div>
744
+
745
+ <div class="panel rise" style="animation-delay:0.16s">
746
+ <div class="panel__head">
747
+ <span class="panel__title">
748
+ <svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="color:var(--ink-3)"><line x1="8" y1="6" x2="21" y2="6"/><line x1="8" y1="12" x2="21" y2="12"/><line x1="8" y1="18" x2="21" y2="18"/><line x1="3" y1="6" x2="3.01" y2="6"/><line x1="3" y1="12" x2="3.01" y2="12"/><line x1="3" y1="18" x2="3.01" y2="18"/></svg>
749
+ Parsed meta
750
+ </span>
751
+ ${copyButton('facts', 'Copy facts')}
752
+ </div>
753
+ <div class="panel__body">
754
+ <dl class="facts">
755
+ ${facts.map((f) => factRow(f.key, f.value, f.count)).join('')}
756
+ </dl>
757
+ <h3 class="subhead">Resolved inputs</h3>
758
+ <div class="resolved" aria-label="Resolved metadata inputs">
759
+ ${resolvedInputs.map((input) => `<div class="resolved__row">
760
+ <span class="resolved__platform">${escapeHtml(input.platform)}</span>
761
+ <span class="resolved__field">${escapeHtml(input.field)}</span>
762
+ <span class="resolved__source"><b>${escapeHtml(input.source)}</b>${input.fallback ? ' · fallback' : ''}</span>
763
+ </div>`).join('')}
764
+ </div>
765
+ </div>
766
+ </div>
767
+ </div>
768
+ </section>
769
+
770
+ <section class="wrap section" aria-labelledby="repair-title">
771
+ <div class="panel rise" style="animation-delay:0.2s">
772
+ <div class="panel__body repair">
773
+ <div class="repair__head">
774
+ <div>
775
+ <h2 class="repair__title" id="repair-title">Repair handoff</h2>
776
+ <p class="repair__copy">Review the safe metadata starting point, then copy the evidence-led brief or guarded coding-agent prompt. Missing facts remain comments instead of invented values.</p>
777
+ </div>
778
+ <div class="repair__actions">
779
+ ${copyButton('snippet', 'Copy metadata')}
780
+ ${copyButton('repair', 'Copy repair brief')}
781
+ ${copyButton('agent', 'Copy agent prompt', true)}
782
+ </div>
783
+ </div>
784
+ <div class="repair__outputs">
785
+ <details class="repair__output" open>
786
+ <summary>Metadata starting point</summary>
787
+ <pre><code>${escapeHtml(metaSnippet)}</code></pre>
788
+ </details>
789
+ <details class="repair__output">
790
+ <summary>Coding-agent prompt</summary>
791
+ <pre>${escapeHtml(agentPrompt)}</pre>
792
+ </details>
793
+ </div>
794
+ </div>
795
+ </div>
796
+ </section>
797
+ <p class="sr-only" id="copy-status" aria-live="polite"></p>
798
+ </main>
799
+
800
+ <footer class="footer">
801
+ <div class="wrap footer__inner">
802
+ <span>fetched ${escapeHtml(report.fetchedAt)}</span>
803
+ <span class="footer__brand">local report by <b>metaprev</b> · platform caches not inspected</span>
804
+ </div>
805
+ </footer>
806
+
807
+ <script id="metaprev-data" type="application/json" nonce="${scriptNonce}">${escapeForScriptJson(copyPayloads)}</script>
808
+ <script nonce="${scriptNonce}">
809
+ (function () {
810
+ var stage = document.getElementById('stage');
811
+ var segButtons = document.querySelectorAll('[data-appearance-set]');
812
+ function setAppearance(mode) {
813
+ if (stage) stage.setAttribute('data-appearance', mode);
814
+ segButtons.forEach(function (b) {
815
+ b.setAttribute('aria-pressed', b.getAttribute('data-appearance-set') === mode ? 'true' : 'false');
816
+ });
817
+ }
818
+ segButtons.forEach(function (b) {
819
+ b.addEventListener('click', function () { setAppearance(b.getAttribute('data-appearance-set')); });
820
+ });
821
+
822
+ var node = document.getElementById('metaprev-data');
823
+ var copyStatus = document.getElementById('copy-status');
824
+ var payloads = {};
825
+ try { payloads = JSON.parse((node && node.textContent) || '{}'); } catch (e) {}
826
+ document.addEventListener('click', function (event) {
827
+ var btn = event.target && event.target.closest && event.target.closest('.copy-btn');
828
+ if (!btn) return;
829
+ var key = btn.getAttribute('data-copy-target');
830
+ var text = key && payloads[key];
831
+ if (!text) return;
832
+ var label = btn.querySelector('.copy-btn__label');
833
+ var originalLabel = label ? label.textContent : null;
834
+ var done = function () {
835
+ btn.setAttribute('data-state', 'copied');
836
+ if (label) label.textContent = 'Copied';
837
+ if (copyStatus) copyStatus.textContent = (originalLabel || 'Content') + ' copied to clipboard.';
838
+ setTimeout(function () {
839
+ btn.removeAttribute('data-state');
840
+ if (label && originalLabel !== null) label.textContent = originalLabel;
841
+ }, 1500);
842
+ };
843
+ var failed = function () {
844
+ if (label) label.textContent = 'Copy failed';
845
+ if (copyStatus) copyStatus.textContent = 'Copy failed. Open the matching output and copy it manually.';
846
+ setTimeout(function () { if (label && originalLabel !== null) label.textContent = originalLabel; }, 2000);
847
+ };
848
+ if (navigator.clipboard && navigator.clipboard.writeText) {
849
+ navigator.clipboard.writeText(text).then(done, function () { fallback(text, done, failed); });
850
+ } else { fallback(text, done, failed); }
851
+ });
852
+ function fallback(text, done, failed) {
853
+ var ta = document.createElement('textarea');
854
+ ta.value = text; ta.setAttribute('readonly', '');
855
+ ta.style.position = 'fixed'; ta.style.opacity = '0';
856
+ document.body.appendChild(ta); ta.select();
857
+ try { document.execCommand('copy') ? done() : failed(); } catch (e) { failed(); } finally { document.body.removeChild(ta); }
858
+ }
859
+ })();
860
+ </script>
861
+ </body>
862
+ </html>`
863
+ }
864
+
865
+ function imgBlock(p: CardParts): string {
866
+ return p.hasImage
867
+ ? `<div class="mock__img" style="background-image:url('${p.cssImage}')" role="img" aria-label="${p.alt}"></div>`
868
+ : `<div class="mock__img mock__img--missing">${escapeHtml(p.missingText)}</div>`
869
+ }
870
+
871
+ function cardMock(label: string, variant: 'fb' | 'x' | 'li' | 'dc', p: CardParts, note: string): string {
872
+ let mock: string
873
+ if (variant === 'fb') {
874
+ mock = `<div class="mock mock--fb">
875
+ ${imgBlock(p)}
876
+ <div class="mock__body">
877
+ <div class="mock__site">${p.host}</div>
878
+ <div class="mock__title mock__line-clamp">${p.title}</div>
879
+ ${p.desc ? `<div class="mock__desc mock__line-clamp">${p.desc}</div>` : ''}
880
+ </div>
881
+ </div>`
882
+ } else if (variant === 'x') {
883
+ // Keep the two declared X card treatments distinct. This is a representative
884
+ // workspace, not a claim that every account experiment renders pixel-for-pixel.
885
+ mock = !p.compact && p.hasImage
886
+ ? `<div class="mock mock--x">
887
+ <div class="mock__shot">
888
+ <div class="mock__img" style="background-image:url('${p.cssImage}')" role="img" aria-label="${p.alt}"></div>
889
+ <span class="mock__domain">${p.host}</span>
890
+ </div>
891
+ </div>`
892
+ : `<div class="mock mock--x">
893
+ <div class="mock__summary${p.compact && p.hasImage ? ' mock__summary--with-image' : ''}">
894
+ <div class="mock__body">
895
+ <div class="mock__site">${p.host}</div>
896
+ <div class="mock__title mock__line-clamp">${p.title}</div>
897
+ ${p.desc ? `<div class="mock__desc mock__line-clamp">${p.desc}</div>` : ''}
898
+ </div>
899
+ ${p.compact && p.hasImage ? `<div class="mock__thumb" style="background-image:url('${p.cssImage}')" role="img" aria-label="${p.alt}"></div>` : ''}
900
+ </div>
901
+ </div>`
902
+ } else if (variant === 'li') {
903
+ mock = `<div class="mock mock--li">
904
+ ${imgBlock(p)}
905
+ <div class="mock__body">
906
+ <div class="mock__title mock__line-clamp">${p.title}</div>
907
+ <div class="mock__site">${p.host}</div>
908
+ </div>
909
+ </div>`
910
+ } else {
911
+ mock = `<div class="mock mock--dc">
912
+ <div class="mock__body">
913
+ <div class="mock__site">${p.site}</div>
914
+ <div class="mock__title mock__line-clamp">${p.title}</div>
915
+ ${p.desc ? `<div class="mock__desc mock__line-clamp">${p.desc}</div>` : ''}
916
+ </div>
917
+ ${imgBlock(p)}
918
+ </div>`
919
+ }
920
+
921
+ return `<article class="card">
922
+ <div class="card__head">
923
+ <span class="card__mark">${MARKS[variant]}</span>
924
+ <h3 class="card__name">${escapeHtml(label)}</h3>
925
+ <span class="card__note">${escapeHtml(note)}</span>
926
+ </div>
927
+ ${mock}
928
+ </article>`
929
+ }
930
+
931
+ type CopyTarget = 'issues' | 'facts' | 'snippet' | 'repair' | 'agent'
932
+
933
+ function copyButton(target: CopyTarget, label: string, primary = false): string {
934
+ return `<button class="copy-btn${primary ? ' copy-btn--primary' : ''}" type="button" data-copy-target="${target}">
935
+ <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="9" y="9" width="13" height="13" rx="2"/><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/></svg>
936
+ <span class="copy-btn__label">${escapeHtml(label)}</span>
937
+ </button>`
938
+ }
939
+
940
+ type Fact = { key: string; value?: string; count?: string }
941
+
942
+ function factRow(label: string, value: string | undefined, countSuffix?: string): string {
943
+ const empty = !value
944
+ return `<div class="fact">
945
+ <dt class="fact__key">${escapeHtml(label)}</dt>
946
+ <dd class="fact__val${empty ? ' is-empty' : ''}">${empty ? '—' : escapeHtml(value)}${countSuffix ? ` <span class="fact__count">${escapeHtml(countSuffix)}</span>` : ''}</dd>
947
+ </div>`
948
+ }
949
+
950
+ function count(s: string | undefined): string | undefined {
951
+ if (!s) return undefined
952
+ return `${s.length} ch`
953
+ }
954
+
955
+ function buildCopyPayloads(report: Report, facts: Fact[]): Record<string, string> {
956
+ const payloads: Record<string, string> = {}
957
+
958
+ if (report.issues.length > 0) {
959
+ payloads.issues = buildFindingsText(report)
960
+ }
961
+ payloads.repair = buildRepairBrief(report)
962
+ payloads.agent = buildAgentPrompt(report)
963
+ payloads.snippet = buildMetaSnippet(report)
964
+
965
+ const width = Math.max(...facts.map((f) => f.key.length))
966
+ payloads.facts = `metaprev — ${report.finalUrl}\n\n${facts
967
+ .map((f) => `${f.key.padEnd(width)} ${f.value ? `${f.value}${f.count ? ` (${f.count})` : ''}` : '—'}`)
968
+ .join('\n')}\n`
969
+
970
+ return payloads
971
+ }
972
+
973
+ function escapeForScriptJson(payload: unknown): string {
974
+ return JSON.stringify(payload)
975
+ .replace(/&/g, '\\u0026')
976
+ .replace(/</g, '\\u003c')
977
+ .replace(/>/g, '\\u003e')
978
+ .replace(/\u2028/g, '\\u2028')
979
+ .replace(/\u2029/g, '\\u2029')
980
+ }