@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/CHANGELOG.md +83 -0
- package/LICENSE +21 -0
- package/README.md +172 -0
- package/bin/metaprev.mjs +25 -0
- package/bin/metaprev.ts +353 -0
- package/package.json +57 -0
- package/skills/metaprev/SKILL.md +156 -0
- package/src/fetch.ts +215 -0
- package/src/format.ts +5 -0
- package/src/host.ts +93 -0
- package/src/inputs.ts +94 -0
- package/src/parse.ts +184 -0
- package/src/render.ts +980 -0
- package/src/repair.ts +171 -0
- package/src/types.ts +60 -0
- package/src/validate.ts +206 -0
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, '<')
|
|
27
|
+
.replace(/>/g, '>')
|
|
28
|
+
.replace(/"/g, '"')
|
|
29
|
+
.replace(/'/g, ''')
|
|
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
|
+
}
|