From 5ac6751e390ba53be527f944f80510ac2a6c2796 Mon Sep 17 00:00:00 2001 From: David Pine <7679720+IEvangelist@users.noreply.github.com> Date: Mon, 29 Jun 2026 15:40:45 -0500 Subject: [PATCH 01/21] Add OpenGraph preview canvas extension Previews OpenGraph metadata as rendered by Facebook, X, LinkedIn, Slack, and Discord. Supports localhost URLs and exposes preview_url and get_metadata canvas actions. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .github/extensions/og-preview/README.md | 51 ++ .../og-preview/copilot-extension.json | 4 + .github/extensions/og-preview/extension.mjs | 262 +++++++++ .../extensions/og-preview/lib/http-fetch.mjs | 122 +++++ .../extensions/og-preview/lib/parse-og.mjs | 178 ++++++ .github/extensions/og-preview/ui/app.js | 320 +++++++++++ .github/extensions/og-preview/ui/index.html | 61 +++ .github/extensions/og-preview/ui/styles.css | 517 ++++++++++++++++++ 8 files changed, 1515 insertions(+) create mode 100644 .github/extensions/og-preview/README.md create mode 100644 .github/extensions/og-preview/copilot-extension.json create mode 100644 .github/extensions/og-preview/extension.mjs create mode 100644 .github/extensions/og-preview/lib/http-fetch.mjs create mode 100644 .github/extensions/og-preview/lib/parse-og.mjs create mode 100644 .github/extensions/og-preview/ui/app.js create mode 100644 .github/extensions/og-preview/ui/index.html create mode 100644 .github/extensions/og-preview/ui/styles.css diff --git a/.github/extensions/og-preview/README.md b/.github/extensions/og-preview/README.md new file mode 100644 index 000000000..44363cccf --- /dev/null +++ b/.github/extensions/og-preview/README.md @@ -0,0 +1,51 @@ +# OpenGraph Preview canvas + +A GitHub Copilot App **canvas extension** that loads any URL — including local dev +servers like `http://localhost:3000` — and shows how it unfurls across social +platforms, alongside a raw OpenGraph metadata view and a diagnostics checklist. + +## Features + +- **Platform previews** — OpenGraph/Facebook, X (Twitter, both `summary` and + `summary_large_image` layouts), LinkedIn, Slack, and Discord. +- **Raw metadata** — every `og:*`, `twitter:*`, and other `` tag, grouped, + with a one-click **Copy JSON**. +- **Diagnostics** — checks for the required/recommended OpenGraph tags. +- **localhost support** — fetches are made by the extension process over plain + `http`/`https`, so loopback URLs work. +- **Image proxy fallback** — preview images that block hotlinking are retried + through a local proxy. + +## How it works + +Each open canvas instance runs a small loopback HTTP server (`127.0.0.1`, random +port) that serves the static UI from `ui/` and a JSON API: + +| Route | Purpose | +| --- | --- | +| `GET /` | Renderer page (auto-loads `?u=`) | +| `GET /api/fetch?u=` | Fetch + parse the target, return metadata JSON | +| `GET /api/img?u=` | Image proxy fallback | +| `GET /events` | Server-Sent Events; agent-driven loads are pushed here | + +The target page is fetched and parsed server-side (no external dependencies), +which sidesteps browser CORS and lets it reach `localhost`. + +## Agent actions + +- **`preview_url`** `{ url }` — load a URL into the open canvas and return its + resolved preview fields. +- **`get_metadata`** `{ url }` — fetch + parse a URL and return all raw metadata + as JSON, without opening the canvas. + +## Files + +``` +og-preview/ + extension.mjs wiring: server, routes, canvas declaration + actions + lib/http-fetch.mjs dependency-free http/https fetch (redirects, timeout) + lib/parse-og.mjs meta-tag parser -> resolved fields, groups, diagnostics + ui/index.html renderer markup + ui/styles.css platform-styled cards + app-theme chrome + ui/app.js client logic (fetch, render, tabs, SSE) +``` diff --git a/.github/extensions/og-preview/copilot-extension.json b/.github/extensions/og-preview/copilot-extension.json new file mode 100644 index 000000000..e496b547f --- /dev/null +++ b/.github/extensions/og-preview/copilot-extension.json @@ -0,0 +1,4 @@ +{ + "name": "og-preview", + "version": 1 +} diff --git a/.github/extensions/og-preview/extension.mjs b/.github/extensions/og-preview/extension.mjs new file mode 100644 index 000000000..02ff54214 --- /dev/null +++ b/.github/extensions/og-preview/extension.mjs @@ -0,0 +1,262 @@ +// Extension: og-preview +// A GitHub Copilot App canvas that loads a URL (including localhost), parses its +// OpenGraph / Twitter / meta tags, and renders platform-styled previews plus a +// raw-metadata and diagnostics view. +// +// Each open canvas instance gets its own loopback HTTP server (ephemeral port) +// that serves the static UI and a small JSON API. The canvas also exposes two +// agent-callable actions: `preview_url` (drive the open canvas to a URL) and +// `get_metadata` (fetch + parse without a canvas, returning raw metadata). + +import { createServer } from "node:http"; +import { readFile } from "node:fs/promises"; +import { fileURLToPath } from "node:url"; +import { extname } from "node:path"; + +import { joinSession, createCanvas, CanvasError } from "@github/copilot-sdk/extension"; + +import { fetchUrl, normalizeUrl } from "./lib/http-fetch.mjs"; +import { parseMetadata } from "./lib/parse-og.mjs"; + +const UI_DIR = new URL("./ui/", import.meta.url); + +const CONTENT_TYPES = { + ".html": "text/html; charset=utf-8", + ".css": "text/css; charset=utf-8", + ".js": "text/javascript; charset=utf-8", +}; + +// instanceId -> { server, url, currentUrl, clients:Set } +const instances = new Map(); + +let sessionRef = null; +function log(message, level = "info") { + try { + sessionRef?.log?.(message, { level, ephemeral: true }); + } catch { + /* logging is best-effort */ + } +} + +async function serveAsset(res, name) { + try { + const buf = await readFile(fileURLToPath(new URL(name, UI_DIR))); + res.setHeader("Content-Type", CONTENT_TYPES[extname(name)] || "application/octet-stream"); + res.end(buf); + } catch { + res.statusCode = 404; + res.end("Not found"); + } +} + +/** Fetch a target URL and parse its OpenGraph metadata. */ +async function loadMetadata(rawUrl) { + const target = normalizeUrl(rawUrl); + const result = await fetchUrl(target); + if (result.status >= 400) { + throw new Error(`Target responded with HTTP ${result.status}.`); + } + if (result.contentType && !/html|xml|text\/plain/i.test(result.contentType)) { + throw new Error(`Target is not an HTML page (Content-Type: ${result.contentType}).`); + } + const html = result.body.toString("utf8"); + const data = parseMetadata(html, result.url); + data.requestedUrl = result.url; + data.httpStatus = result.status; + return data; +} + +function sendJson(res, status, obj) { + res.statusCode = status; + res.setHeader("Content-Type", "application/json; charset=utf-8"); + res.setHeader("Cache-Control", "no-store"); + res.end(JSON.stringify(obj)); +} + +function broadcast(entry, payload) { + const data = `data: ${JSON.stringify(payload)}\n\n`; + for (const client of entry.clients) { + try { + client.write(data); + } catch { + /* client gone */ + } + } +} + +async function handleRequest(entry, req, res) { + const reqUrl = new URL(req.url, "http://127.0.0.1"); + const path = reqUrl.pathname; + + if (path === "/" || path === "/index.html") { + return serveAsset(res, "index.html"); + } + if (path === "/styles.css" || path === "/app.js") { + return serveAsset(res, path.slice(1)); + } + + if (path === "/events") { + res.writeHead(200, { + "Content-Type": "text/event-stream", + "Cache-Control": "no-cache", + Connection: "keep-alive", + }); + res.write(": connected\n\n"); + entry.clients.add(res); + if (entry.currentUrl) { + res.write(`data: ${JSON.stringify({ type: "load", url: entry.currentUrl })}\n\n`); + } + req.on("close", () => entry.clients.delete(res)); + return; + } + + if (path === "/api/fetch") { + const u = reqUrl.searchParams.get("u"); + if (!u) return sendJson(res, 400, { error: "Missing 'u' query parameter." }); + try { + const data = await loadMetadata(u); + entry.currentUrl = data.requestedUrl; + return sendJson(res, 200, data); + } catch (err) { + return sendJson(res, 200, { error: err.message }); + } + } + + if (path === "/api/img") { + const u = reqUrl.searchParams.get("u"); + if (!u) { + res.statusCode = 400; + return res.end("Missing 'u'"); + } + try { + const img = await fetchUrl(u, { accept: "image/*,*/*;q=0.8", timeoutMs: 12000 }); + res.statusCode = img.status >= 400 ? img.status : 200; + res.setHeader("Content-Type", img.contentType || "application/octet-stream"); + res.setHeader("Cache-Control", "public, max-age=300"); + return res.end(img.body); + } catch { + res.statusCode = 502; + return res.end("Image fetch failed"); + } + } + + res.statusCode = 404; + res.end("Not found"); +} + +async function startServer(instanceId, currentUrl) { + const entry = { server: null, url: "", currentUrl: currentUrl || "", clients: new Set() }; + const server = createServer((req, res) => { + Promise.resolve(handleRequest(entry, req, res)).catch((err) => { + if (!res.headersSent) res.statusCode = 500; + res.end(String(err && err.message ? err.message : err)); + }); + }); + await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve)); + const address = server.address(); + const port = typeof address === "object" && address ? address.port : 0; + entry.server = server; + entry.url = `http://127.0.0.1:${port}/`; + instances.set(instanceId, entry); + return entry; +} + +function instanceUrl(entry) { + const base = entry.url; + return entry.currentUrl ? `${base}?u=${encodeURIComponent(entry.currentUrl)}` : base; +} + +const ogCanvas = createCanvas({ + id: "og-preview", + displayName: "OpenGraph Preview", + description: + "Preview how a URL unfurls on Facebook, X, LinkedIn, Slack, and Discord, with a raw OpenGraph metadata and diagnostics view. Supports localhost.", + inputSchema: { + type: "object", + properties: { + url: { + type: "string", + description: "Optional URL to load immediately (supports http://localhost).", + }, + }, + additionalProperties: false, + }, + actions: [ + { + name: "preview_url", + description: + "Load a URL into the open OpenGraph Preview canvas and return its parsed metadata.", + inputSchema: { + type: "object", + properties: { url: { type: "string", description: "URL to preview." } }, + required: ["url"], + additionalProperties: false, + }, + handler: async (ctx) => { + const entry = instances.get(ctx.instanceId); + if (!entry) { + throw new CanvasError("canvas_not_open", "Open the OpenGraph Preview canvas first."); + } + const url = ctx.input?.url; + if (!url) throw new CanvasError("invalid_input", "An 'url' value is required."); + const data = await loadMetadata(url); + entry.currentUrl = data.requestedUrl; + broadcast(entry, { type: "load", url: data.requestedUrl }); + return { requestedUrl: data.requestedUrl, resolved: data.resolved }; + }, + }, + { + name: "get_metadata", + description: + "Fetch a URL and return all parsed OpenGraph / Twitter / meta tags as raw JSON, without requiring the canvas to be open. Supports localhost.", + inputSchema: { + type: "object", + properties: { url: { type: "string", description: "URL to inspect." } }, + required: ["url"], + additionalProperties: false, + }, + handler: async (ctx) => { + const url = ctx.input?.url; + if (!url) throw new CanvasError("invalid_input", "An 'url' value is required."); + const data = await loadMetadata(url); + return { + requestedUrl: data.requestedUrl, + resolved: data.resolved, + raw: data.raw, + diagnostics: data.diagnostics, + }; + }, + }, + ], + open: async (ctx) => { + let entry = instances.get(ctx.instanceId); + const inputUrl = ctx.input?.url; + if (!entry) { + entry = await startServer(ctx.instanceId, inputUrl); + } else if (inputUrl) { + entry.currentUrl = inputUrl; + broadcast(entry, { type: "load", url: inputUrl }); + } + log(`OpenGraph Preview canvas opened (${ctx.instanceId}).`); + return { + title: entry.currentUrl ? `OG · ${entry.currentUrl}` : "OpenGraph Preview", + status: entry.currentUrl ? "Loaded" : "Ready", + url: instanceUrl(entry), + }; + }, + onClose: async (ctx) => { + const entry = instances.get(ctx.instanceId); + if (!entry) return; + instances.delete(ctx.instanceId); + for (const client of entry.clients) { + try { + client.end(); + } catch { + /* ignore */ + } + } + await new Promise((resolve) => entry.server.close(() => resolve())); + }, +}); + +sessionRef = await joinSession({ canvases: [ogCanvas] }); diff --git a/.github/extensions/og-preview/lib/http-fetch.mjs b/.github/extensions/og-preview/lib/http-fetch.mjs new file mode 100644 index 000000000..fdc357e58 --- /dev/null +++ b/.github/extensions/og-preview/lib/http-fetch.mjs @@ -0,0 +1,122 @@ +// Dependency-free HTTP(S) fetch with redirect following, timeout, and a size +// cap. Uses Node's built-in http/https so it works regardless of whether the +// host Node has global fetch, and reliably reaches localhost / 127.0.0.1. + +import http from "node:http"; +import https from "node:https"; + +const USER_AGENT = + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 " + + "(KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36 CopilotOGPreview/1.0"; + +const LOCAL_HOST_RE = /^(localhost|127\.0\.0\.1|0\.0\.0\.0|\[::1\]|::1|.*\.localhost)(:|\/|$)/i; + +/** + * Normalize user input into an absolute URL. Bare localhost-ish hosts default + * to http://, everything else defaults to https://. + */ +export function normalizeUrl(input) { + const trimmed = String(input ?? "").trim(); + if (!trimmed) throw new Error("No URL provided."); + if (/^https?:\/\//i.test(trimmed)) return trimmed; + const scheme = LOCAL_HOST_RE.test(trimmed) ? "http://" : "https://"; + return scheme + trimmed; +} + +const MAX_BYTES = 6 * 1024 * 1024; // 6 MB safety cap + +/** + * Fetch a URL, following redirects. Resolves with + * { url, status, headers, contentType, body: Buffer }. + */ +export function fetchUrl(rawUrl, options = {}) { + const { + maxRedirects = 6, + timeoutMs = 15000, + accept = "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8", + maxBytes = MAX_BYTES, + } = options; + + return new Promise((resolve, reject) => { + let redirects = 0; + + const visit = (urlStr) => { + let parsed; + try { + parsed = new URL(urlStr); + } catch { + return reject(new Error(`Invalid URL: ${urlStr}`)); + } + if (parsed.protocol !== "http:" && parsed.protocol !== "https:") { + return reject(new Error(`Unsupported protocol: ${parsed.protocol}`)); + } + + const lib = parsed.protocol === "https:" ? https : http; + const req = lib.request( + parsed, + { + method: "GET", + headers: { + "User-Agent": USER_AGENT, + Accept: accept, + "Accept-Language": "en-US,en;q=0.9", + }, + }, + (res) => { + const status = res.statusCode || 0; + const location = res.headers.location; + + if (status >= 300 && status < 400 && location) { + res.resume(); + if (redirects >= maxRedirects) { + return reject(new Error("Too many redirects.")); + } + redirects += 1; + let next; + try { + next = new URL(location, parsed).toString(); + } catch { + return reject(new Error(`Bad redirect target: ${location}`)); + } + return visit(next); + } + + const chunks = []; + let total = 0; + let aborted = false; + res.on("data", (chunk) => { + total += chunk.length; + if (total > maxBytes) { + aborted = true; + req.destroy(); + res.destroy(); + return; + } + chunks.push(chunk); + }); + res.on("end", () => { + if (aborted) return; + resolve({ + url: parsed.toString(), + status, + headers: res.headers, + contentType: String(res.headers["content-type"] || ""), + body: Buffer.concat(chunks), + }); + }); + res.on("error", (err) => { + if (!aborted) reject(err); + }); + }, + ); + + req.setTimeout(timeoutMs, () => { + req.destroy(new Error(`Request timed out after ${timeoutMs}ms.`)); + }); + req.on("error", (err) => reject(err)); + req.end(); + }; + + visit(rawUrl); + }); +} diff --git a/.github/extensions/og-preview/lib/parse-og.mjs b/.github/extensions/og-preview/lib/parse-og.mjs new file mode 100644 index 000000000..40be0a333 --- /dev/null +++ b/.github/extensions/og-preview/lib/parse-og.mjs @@ -0,0 +1,178 @@ +// Regex-based OpenGraph / Twitter / meta-tag parser. No external deps. +// Produces resolved preview fields, ordered + grouped raw tags, and diagnostics. + +function decodeEntities(input) { + if (!input) return input; + return String(input) + .replace(/&#x([0-9a-fA-F]+);/g, (_, hex) => codePoint(parseInt(hex, 16))) + .replace(/&#(\d+);/g, (_, dec) => codePoint(parseInt(dec, 10))) + .replace(/"/gi, '"') + .replace(/'/gi, "'") + .replace(/</gi, "<") + .replace(/>/gi, ">") + .replace(/ /gi, " ") + .replace(/&/gi, "&"); +} + +function codePoint(cp) { + if (!Number.isFinite(cp) || cp < 0 || cp > 0x10ffff) return ""; + try { + return String.fromCodePoint(cp); + } catch { + return ""; + } +} + +function extractTags(html, tagName) { + const re = new RegExp(`<${tagName}\\b[^>]*?/?>`, "gi"); + const out = []; + let m; + while ((m = re.exec(html)) !== null) out.push(m[0]); + return out; +} + +function parseAttrs(tagStr) { + const attrs = {}; + const re = /([a-zA-Z_:][-a-zA-Z0-9_:.]*)\s*=\s*("([^"]*)"|'([^']*)'|([^\s"'=<>`]+))/g; + let m; + while ((m = re.exec(tagStr)) !== null) { + const key = m[1].toLowerCase(); + const val = m[3] ?? m[4] ?? m[5] ?? ""; + attrs[key] = decodeEntities(val); + } + return attrs; +} + +function resolveUrl(value, baseUrl) { + if (!value) return ""; + try { + return new URL(value, baseUrl).toString(); + } catch { + return value; + } +} + +/** + * Parse OpenGraph and related metadata out of an HTML document. + * @param {string} html + * @param {string} baseUrl - the (final) URL the HTML was fetched from + */ +export function parseMetadata(html, baseUrl) { + const all = []; // ordered { key, value } + + for (const tag of extractTags(html, "meta")) { + const a = parseAttrs(tag); + if (a.charset) { + all.push({ key: "charset", value: a.charset }); + continue; + } + const key = a.property || a.name || a.itemprop; + if (!key || typeof a.content !== "string") continue; + all.push({ key: key.toLowerCase().trim(), value: a.content }); + } + + // + const titleMatch = html.match(/<title[^>]*>([\s\S]*?)<\/title>/i); + const htmlTitle = titleMatch ? decodeEntities(titleMatch[1]).trim() : ""; + + // <link> tags: icons + canonical + let canonical = ""; + const icons = []; + for (const tag of extractTags(html, "link")) { + const a = parseAttrs(tag); + const rel = (a.rel || "").toLowerCase(); + if (!rel || !a.href) continue; + if (rel.includes("canonical")) canonical = a.href; + if (rel.includes("icon")) { + icons.push({ rel, href: resolveUrl(a.href, baseUrl), sizes: a.sizes || "" }); + } + } + + const first = (k) => { + const f = all.find((x) => x.key === k.toLowerCase()); + return f ? f.value : ""; + }; + + const ogImageRaw = + first("og:image:secure_url") || first("og:image:url") || first("og:image"); + const twImageRaw = first("twitter:image") || first("twitter:image:src"); + + let hostname = ""; + try { + hostname = new URL(baseUrl).hostname; + } catch { + hostname = baseUrl; + } + + const image = resolveUrl(ogImageRaw || twImageRaw, baseUrl); + const favicon = + icons.find((i) => i.rel.includes("apple-touch"))?.href || + icons.find((i) => i.rel === "icon" || i.rel.includes("shortcut"))?.href || + icons[0]?.href || + resolveUrl("/favicon.ico", baseUrl); + + const resolved = { + title: first("og:title") || first("twitter:title") || htmlTitle, + description: + first("og:description") || + first("twitter:description") || + first("description"), + image, + imageAlt: first("og:image:alt") || first("twitter:image:alt"), + siteName: first("og:site_name"), + hostname, + url: first("og:url") || canonical || baseUrl, + type: first("og:type"), + locale: first("og:locale"), + themeColor: first("theme-color"), + favicon, + twitterCard: first("twitter:card"), + twitterSite: first("twitter:site"), + twitterCreator: first("twitter:creator"), + }; + + // Grouped raw view + const groups = { openGraph: [], twitter: [], other: [] }; + for (const item of all) { + if (item.key.startsWith("og:")) groups.openGraph.push(item); + else if (item.key.startsWith("twitter:")) groups.twitter.push(item); + else groups.other.push(item); + } + + const diagnostics = [ + check("og:title", !!first("og:title"), "required", "Primary title shown in shares."), + check("og:type", !!first("og:type"), "recommended", "e.g. website, article, video."), + check("og:image", !!ogImageRaw, "required", "The preview image. ~1200×630 recommended."), + check("og:url", !!first("og:url"), "recommended", "Canonical URL of the object."), + check("og:description", !!first("og:description"), "recommended", "Short summary (<200 chars)."), + check("og:site_name", !!resolved.siteName, "optional", "Human-readable site name."), + check("twitter:card", !!resolved.twitterCard, "recommended", "Controls X/Twitter card layout."), + check( + "Absolute og:image URL", + /^https?:\/\//i.test(image), + "recommended", + "Crawlers require absolute image URLs.", + ), + check( + "Description length OK", + !resolved.description || resolved.description.length <= 300, + "optional", + `Description is ${resolved.description.length} chars.`, + ), + ]; + + return { + requestedUrl: baseUrl, + resolved, + raw: all, + groups, + icons, + diagnostics, + htmlTitle, + tagCount: all.length, + }; +} + +function check(id, ok, level, note) { + return { id, ok, level, note }; +} diff --git a/.github/extensions/og-preview/ui/app.js b/.github/extensions/og-preview/ui/app.js new file mode 100644 index 000000000..708157c7a --- /dev/null +++ b/.github/extensions/og-preview/ui/app.js @@ -0,0 +1,320 @@ +"use strict"; + +const TRANSPARENT = + "data:image/gif;base64,R0lGODlhAQABAAAAACH5BAEKAAEALAAAAAABAAEAAAICTAEAOw=="; + +const $ = (sel) => document.querySelector(sel); +const input = $("#url-input"); +const statusEl = $("#status"); +let lastData = null; + +function el(tag, props, children) { + const node = document.createElement(tag); + if (props) { + for (const [k, v] of Object.entries(props)) { + if (v == null) continue; + if (k === "class") node.className = v; + else if (k === "text") node.textContent = v; + else if (k === "html") node.innerHTML = v; + else node.setAttribute(k, v); + } + } + for (const c of [].concat(children || [])) { + if (c == null) continue; + node.appendChild(typeof c === "string" ? document.createTextNode(c) : c); + } + return node; +} + +function prettyDomain(host) { + return String(host || "").replace(/^www\./i, ""); +} + +/** Image element with direct -> proxy -> placeholder fallback chain. */ +function makeImage(url, className) { + if (!url) return el("div", { class: className }); + const img = el("img", { class: className, src: url, alt: "", referrerpolicy: "no-referrer" }); + img.dataset.stage = "direct"; + img.addEventListener("error", () => { + if (img.dataset.stage === "direct") { + img.dataset.stage = "proxy"; + img.src = "/api/img?u=" + encodeURIComponent(url); + } else if (img.dataset.stage === "proxy") { + img.dataset.stage = "placeholder"; + img.src = TRANSPARENT; + } + }); + return img; +} + +/* ---------------- Previews ---------------- */ + +function labeledCard(name, color, card) { + return el("div", { class: "preview" }, [ + el("div", { class: "preview-label" }, [ + el("span", { class: "dot", style: `background:${color}` }), + name, + ]), + card, + ]); +} + +function facebookCard(d, domain) { + return el("div", { class: "fb" }, [ + makeImage(d.image, "card-img"), + el("div", { class: "meta" }, [ + el("div", { class: "domain", text: domain }), + el("div", { class: "title", text: d.title || "(no title)" }), + d.description ? el("div", { class: "desc", text: d.description }) : null, + ]), + ]); +} + +function twitterCard(d, domain) { + const isSmall = (d.twitterCard || "").toLowerCase() === "summary"; + if (isSmall) { + return el("div", { class: "x small" }, [ + makeImage(d.image, "card-img"), + el("div", { class: "meta" }, [ + el("div", { class: "domain", text: domain }), + el("div", { class: "title", text: d.title || "(no title)" }), + d.description ? el("div", { class: "desc", text: d.description }) : null, + ]), + ]); + } + return el("div", { class: "x" }, [ + makeImage(d.image, "card-img"), + d.image ? el("span", { class: "domain-pill", text: domain }) : null, + el("div", { class: "meta" }, [ + el("div", { class: "title", text: d.title || "(no title)" }), + d.description ? el("div", { class: "desc", text: d.description }) : null, + ]), + ]); +} + +function linkedinCard(d, domain) { + return el("div", { class: "li" }, [ + makeImage(d.image, "card-img"), + el("div", { class: "meta" }, [ + el("div", { class: "title", text: d.title || "(no title)" }), + el("div", { class: "domain", text: domain }), + ]), + ]); +} + +function slackCard(d, domain) { + const accent = d.themeColor || "#e8e8e8"; + const card = el("div", { class: "slack", style: `border-left-color:${accent}` }, [ + el("div", { class: "site" }, [ + d.favicon ? makeImage(d.favicon, "") : null, + d.siteName || domain, + ]), + el("div", { class: "title", text: d.title || "(no title)" }), + d.description ? el("div", { class: "desc", text: d.description }) : null, + d.image ? makeImage(d.image, "card-img") : null, + ]); + return card; +} + +function discordCard(d, domain) { + const accent = d.themeColor || "#5865f2"; + return el("div", { class: "discord", style: `border-left-color:${accent}` }, [ + el("div", { class: "site", text: d.siteName || domain }), + el("div", { class: "title", text: d.title || "(no title)" }), + d.description ? el("div", { class: "desc", text: d.description }) : null, + d.image ? makeImage(d.image, "card-img") : null, + ]); +} + +function renderPreviews(data) { + const d = data.resolved; + const domain = prettyDomain(d.hostname); + const grid = $("#previews"); + grid.replaceChildren( + labeledCard("OpenGraph · Facebook", "#1877f2", facebookCard(d, domain)), + labeledCard("X · Twitter", "#000000", twitterCard(d, domain)), + labeledCard("LinkedIn", "#0a66c2", linkedinCard(d, domain)), + labeledCard("Slack", "#4a154b", slackCard(d, domain)), + labeledCard("Discord", "#5865f2", discordCard(d, domain)), + ); +} + +/* ---------------- Raw ---------------- */ + +function valueCell(value) { + const td = el("td", { class: "v" }); + if (/^https?:\/\//i.test(value)) { + td.appendChild(el("a", { href: value, target: "_blank", rel: "noreferrer", text: value })); + } else { + td.textContent = value; + } + return td; +} + +function kvTable(rows) { + const table = el("table", { class: "kv" }); + for (const { key, value } of rows) { + table.appendChild( + el("tr", null, [el("td", { class: "k", text: key }), valueCell(value)]), + ); + } + return table; +} + +function rawGroup(title, rows) { + if (!rows || rows.length === 0) return null; + return el("div", { class: "raw-group" }, [ + el("h3", null, [title, el("span", { class: "count muted", text: `(${rows.length})` })]), + kvTable(rows), + ]); +} + +function renderRaw(data) { + const host = $("#raw"); + const iconRows = (data.icons || []).map((i) => ({ + key: i.rel + (i.sizes ? ` ${i.sizes}` : ""), + value: i.href, + })); + host.replaceChildren( + rawGroup("OpenGraph", data.groups.openGraph), + rawGroup("Twitter / X", data.groups.twitter), + rawGroup("Other meta", data.groups.other), + rawGroup("Icons & links", iconRows), + ); + if (!host.childElementCount) { + host.appendChild(el("p", { class: "muted", text: "No metadata tags found." })); + } + $("#raw-summary").textContent = `${data.tagCount} meta tag${data.tagCount === 1 ? "" : "s"} · ${data.requestedUrl}`; +} + +/* ---------------- Diagnostics ---------------- */ + +function renderDiagnostics(data) { + const host = $("#diagnostics"); + host.replaceChildren( + ...data.diagnostics.map((c) => { + let cls = "ok"; + let glyph = "\u2713"; + if (!c.ok) { + if (c.level === "required") { + cls = "req"; + glyph = "\u2715"; + } else { + cls = "warn"; + glyph = "!"; + } + } + return el("div", { class: "diag-item" }, [ + el("div", { class: `diag-mark ${cls}`, text: glyph }), + el("div", null, [ + el("span", { class: "diag-id", text: c.id }), + el("span", { class: "diag-level", text: c.level }), + el("div", { class: "muted", text: c.note || "" }), + ]), + ]); + }), + ); +} + +/* ---------------- Status + load ---------------- */ + +function setStatus(kind, message) { + if (!kind) { + statusEl.hidden = true; + return; + } + statusEl.hidden = false; + statusEl.className = `status ${kind}`; + statusEl.textContent = message; +} + +async function load(rawUrl) { + const url = (rawUrl || "").trim(); + if (!url) return; + document.body.classList.add("has-data", "is-busy"); + setStatus("loading", `Fetching ${url} …`); + try { + const res = await fetch("/api/fetch?u=" + encodeURIComponent(url)); + const data = await res.json(); + if (!res.ok || data.error) { + throw new Error(data.error || `Request failed (${res.status})`); + } + lastData = data; + if (data.resolved.url || data.requestedUrl) { + input.value = data.requestedUrl || url; + } + renderPreviews(data); + renderRaw(data); + renderDiagnostics(data); + setStatus(null); + } catch (err) { + setStatus("error", `Couldn't load metadata: ${err.message}`); + } finally { + document.body.classList.remove("is-busy"); + } +} + +/* ---------------- Wiring ---------------- */ + +$("#url-form").addEventListener("submit", (e) => { + e.preventDefault(); + load(input.value); +}); +$("#refresh").addEventListener("click", () => load(input.value)); + +document.querySelectorAll(".tab").forEach((tab) => { + tab.addEventListener("click", () => { + document.querySelectorAll(".tab").forEach((t) => t.classList.remove("active")); + document.querySelectorAll(".panel").forEach((p) => p.classList.remove("active")); + tab.classList.add("active"); + $("#panel-" + tab.dataset.tab).classList.add("active"); + }); +}); + +$("#copy-json").addEventListener("click", async () => { + if (!lastData) return; + const payload = JSON.stringify( + { + requestedUrl: lastData.requestedUrl, + resolved: lastData.resolved, + raw: lastData.raw, + diagnostics: lastData.diagnostics, + }, + null, + 2, + ); + try { + await navigator.clipboard.writeText(payload); + const btn = $("#copy-json"); + const old = btn.textContent; + btn.textContent = "Copied!"; + setTimeout(() => (btn.textContent = old), 1200); + } catch { + setStatus("error", "Clipboard blocked — JSON is also returned by the get_metadata action."); + } +}); + +// Server-pushed loads (agent invoking the preview_url action). +try { + const es = new EventSource("/events"); + es.addEventListener("message", (e) => { + try { + const msg = JSON.parse(e.data); + if (msg && msg.type === "load" && msg.url) { + input.value = msg.url; + load(msg.url); + } + } catch { + /* ignore */ + } + }); +} catch { + /* SSE unavailable */ +} + +// Auto-load from ?u= on first render. +const initial = new URLSearchParams(location.search).get("u"); +if (initial) { + input.value = initial; + load(initial); +} diff --git a/.github/extensions/og-preview/ui/index.html b/.github/extensions/og-preview/ui/index.html new file mode 100644 index 000000000..a48995504 --- /dev/null +++ b/.github/extensions/og-preview/ui/index.html @@ -0,0 +1,61 @@ +<!doctype html> +<html lang="en"> + <head> + <meta charset="utf-8" /> + <meta name="viewport" content="width=device-width, initial-scale=1" /> + <title>OpenGraph Preview + + + +
+
+ + + +
+ +
+ + + +
+
+
+
+
+
+ + +
+
+
+
+
+
+
+ +
+
🔎
+

Preview OpenGraph metadata

+

+ Enter a URL above to see how it unfurls on social platforms. + Local dev servers like http://localhost:3000 work too. +

+
+ + + + diff --git a/.github/extensions/og-preview/ui/styles.css b/.github/extensions/og-preview/ui/styles.css new file mode 100644 index 000000000..ef16039c1 --- /dev/null +++ b/.github/extensions/og-preview/ui/styles.css @@ -0,0 +1,517 @@ +:root { + --card-radius: 12px; + --gap: 16px; +} + +* { + box-sizing: border-box; +} + +html, +body { + margin: 0; + height: 100%; +} + +body { + background: var(--background-color-default, #ffffff); + color: var(--text-color-default, #1f2328); + font-family: var(--font-sans, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif); + font-size: var(--text-body-medium, 14px); + line-height: var(--leading-body-medium, 20px); + display: flex; + flex-direction: column; +} + +.muted { + color: var(--text-color-muted, #59636e); +} + +code { + font-family: var(--font-mono, "SFMono-Regular", Consolas, "Liberation Mono", monospace); + font-size: var(--text-code-inline, 12px); + background: var(--background-color-muted, rgba(129, 139, 152, 0.12)); + padding: 1px 5px; + border-radius: 5px; +} + +/* ---------- Toolbar ---------- */ +.toolbar { + position: sticky; + top: 0; + z-index: 5; + padding: 12px 16px 0; + background: var(--background-color-default, #ffffff); + border-bottom: 1px solid var(--border-color-default, #d1d9e0); +} + +.url-form { + display: flex; + gap: 8px; + align-items: center; +} + +.url-input { + flex: 1; + min-width: 0; + padding: 8px 12px; + border-radius: 8px; + border: 1px solid var(--border-color-default, #d1d9e0); + background: var(--background-color-inset, #f6f8fa); + color: inherit; + font-size: 14px; + font-family: var(--font-mono, monospace); +} + +.url-input:focus { + outline: 2px solid var(--color-focus-outline, #0969da); + outline-offset: -1px; + border-color: transparent; +} + +.btn { + border: 1px solid var(--border-color-default, #d1d9e0); + background: var(--background-color-default, #ffffff); + color: inherit; + padding: 8px 14px; + border-radius: 8px; + font-size: 14px; + font-weight: var(--font-weight-semibold, 600); + cursor: pointer; + white-space: nowrap; +} + +.btn:hover { + background: var(--background-color-muted, rgba(129, 139, 152, 0.12)); +} + +.btn.primary { + background: var(--true-color-blue, #0969da); + border-color: var(--true-color-blue, #0969da); + color: var(--color-white, #ffffff); +} + +.btn.primary:hover { + filter: brightness(1.05); +} + +.btn.icon { + padding: 8px 12px; + font-size: 16px; + line-height: 1; +} + +.btn.small { + padding: 4px 10px; + font-size: 12px; +} + +.tabs { + display: flex; + gap: 4px; + margin-top: 10px; +} + +.tab { + border: none; + background: none; + color: var(--text-color-muted, #59636e); + padding: 8px 4px; + margin-right: 12px; + font-size: 14px; + font-weight: var(--font-weight-semibold, 600); + cursor: pointer; + border-bottom: 2px solid transparent; +} + +.tab:hover { + color: var(--text-color-default, #1f2328); +} + +.tab.active { + color: var(--text-color-default, #1f2328); + border-bottom-color: var(--true-color-orange, #fb8500); +} + +/* ---------- Status ---------- */ +.status { + margin: 12px 16px 0; + padding: 10px 14px; + border-radius: 8px; + font-size: 13px; + border: 1px solid var(--border-color-default, #d1d9e0); +} + +.status.loading { + background: var(--background-color-inset, #f6f8fa); +} + +.status.error { + background: var(--true-color-red-muted, rgba(255, 129, 130, 0.15)); + border-color: var(--true-color-red, #cf222e); + color: var(--true-color-red, #cf222e); +} + +/* ---------- Panels ---------- */ +.content { + flex: 1; + overflow: auto; + padding: 16px; +} + +.panel { + display: none; +} + +.panel.active { + display: block; +} + +.empty { + margin: auto; + text-align: center; + max-width: 420px; + padding: 48px 24px; +} + +.empty-icon { + font-size: 40px; +} + +.empty-title { + font-size: var(--text-title-medium, 18px); + font-weight: var(--font-weight-semibold, 600); + margin: 8px 0 4px; +} + +.is-busy .empty { + display: none; +} + +/* ---------- Previews grid ---------- */ +.previews-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(360px, 1fr)); + gap: 20px; + align-items: start; +} + +.preview { + min-width: 0; +} + +.preview-label { + display: flex; + align-items: center; + gap: 8px; + font-size: 12px; + font-weight: var(--font-weight-semibold, 600); + text-transform: uppercase; + letter-spacing: 0.04em; + color: var(--text-color-muted, #59636e); + margin-bottom: 8px; +} + +.preview-label .dot { + width: 9px; + height: 9px; + border-radius: 50%; +} + +.card-img { + width: 100%; + display: block; + background: #e9edf1 url("data:image/svg+xml;utf8,") center / 48px no-repeat; + object-fit: cover; + aspect-ratio: 1.91 / 1; + color: transparent; + font-size: 0; +} + +/* Facebook / OpenGraph */ +.fb { + border: 1px solid #dadde1; + border-radius: 8px; + overflow: hidden; + background: #fff; + color: #1c1e21; +} +.fb .meta { + padding: 10px 12px; + background: #f2f3f5; + border-top: 1px solid #dadde1; +} +.fb .domain { + font-size: 12px; + color: #606770; + text-transform: uppercase; +} +.fb .title { + font-size: 16px; + font-weight: 600; + margin: 3px 0; + line-height: 1.25; +} +.fb .desc { + font-size: 14px; + color: #606770; +} + +/* X / Twitter large */ +.x { + border: 1px solid #cfd9de; + border-radius: 16px; + overflow: hidden; + background: #fff; + color: #0f1419; + position: relative; +} +.x .card-img { + aspect-ratio: 1.91 / 1; +} +.x .domain-pill { + position: absolute; + left: 12px; + bottom: 56px; + background: rgba(0, 0, 0, 0.72); + color: #fff; + font-size: 12px; + padding: 1px 6px; + border-radius: 4px; +} +.x .meta { + padding: 10px 12px; +} +.x .title { + font-size: 15px; + font-weight: 400; + color: #0f1419; +} +.x .desc { + font-size: 14px; + color: #536471; + margin-top: 2px; +} +.x .domain { + font-size: 14px; + color: #536471; +} +/* X small (summary) */ +.x.small { + display: flex; + align-items: stretch; +} +.x.small .card-img { + width: 130px; + flex: 0 0 130px; + aspect-ratio: 1 / 1; + border-right: 1px solid #cfd9de; +} +.x.small .meta { + display: flex; + flex-direction: column; + justify-content: center; +} + +/* LinkedIn */ +.li { + border: 1px solid #e0e0e0; + border-radius: 4px; + overflow: hidden; + background: #fff; + color: rgba(0, 0, 0, 0.9); + box-shadow: 0 0 0 1px rgba(0, 0, 0, 0.04); +} +.li .meta { + padding: 10px 12px; + background: #eef3f8; +} +.li .title { + font-size: 15px; + font-weight: 600; + line-height: 1.25; +} +.li .domain { + font-size: 12px; + color: rgba(0, 0, 0, 0.6); + margin-top: 4px; +} + +/* Slack */ +.slack { + background: #fff; + color: #1d1c1d; + padding-left: 12px; + border-left: 4px solid #e8e8e8; +} +.slack .site { + display: flex; + align-items: center; + gap: 6px; + font-size: 13px; + font-weight: 700; + margin-bottom: 2px; +} +.slack .site img { + width: 16px; + height: 16px; + border-radius: 3px; +} +.slack .title { + color: #1264a3; + font-size: 15px; + font-weight: 700; + line-height: 1.3; +} +.slack .desc { + font-size: 14px; + color: #1d1c1d; + margin: 4px 0 8px; +} +.slack .card-img { + max-width: 360px; + border-radius: 8px; + aspect-ratio: auto; + max-height: 220px; + object-fit: cover; +} + +/* Discord */ +.discord { + background: #2b2d31; + color: #dbdee1; + border-radius: 8px; + border-left: 4px solid #5865f2; + padding: 12px 14px; +} +.discord .site { + font-size: 12px; + color: #b5bac1; + margin-bottom: 4px; +} +.discord .title { + color: #00a8fc; + font-size: 16px; + font-weight: 600; + line-height: 1.3; +} +.discord .desc { + font-size: 14px; + color: #dbdee1; + margin: 6px 0 10px; +} +.discord .card-img { + border-radius: 8px; + max-height: 260px; + aspect-ratio: auto; +} + +.card-missing { + padding: 14px; + font-size: 13px; + border: 1px dashed var(--border-color-default, #d1d9e0); + border-radius: 8px; + color: var(--text-color-muted, #59636e); +} + +/* ---------- Raw ---------- */ +.raw-actions { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + margin-bottom: 12px; +} + +.raw-group { + margin-bottom: 22px; +} + +.raw-group h3 { + font-size: 13px; + text-transform: uppercase; + letter-spacing: 0.04em; + color: var(--text-color-muted, #59636e); + margin: 0 0 8px; + display: flex; + align-items: center; + gap: 8px; +} + +.raw-group h3 .count { + font-weight: 400; +} + +table.kv { + width: 100%; + border-collapse: collapse; + font-size: 13px; +} + +table.kv td { + border: 1px solid var(--border-color-muted, #e5e9ed); + padding: 6px 10px; + vertical-align: top; + word-break: break-word; +} + +table.kv td.k { + width: 34%; + font-family: var(--font-mono, monospace); + color: var(--text-color-default, #1f2328); + background: var(--background-color-inset, #f6f8fa); + white-space: nowrap; +} + +table.kv td.v a { + color: var(--true-color-blue, #0969da); + text-decoration: none; +} + +table.kv td.v a:hover { + text-decoration: underline; +} + +/* ---------- Diagnostics ---------- */ +.diag-item { + display: flex; + align-items: flex-start; + gap: 10px; + padding: 10px 0; + border-bottom: 1px solid var(--border-color-muted, #e5e9ed); +} + +.diag-mark { + width: 20px; + height: 20px; + border-radius: 50%; + display: flex; + align-items: center; + justify-content: center; + font-size: 12px; + flex: 0 0 20px; + color: #fff; + margin-top: 1px; +} + +.diag-mark.ok { + background: var(--true-color-green, #1a7f37); +} +.diag-mark.warn { + background: var(--true-color-orange, #bc4c00); +} +.diag-mark.req { + background: var(--true-color-red, #cf222e); +} + +.diag-id { + font-weight: var(--font-weight-semibold, 600); +} + +.diag-level { + font-size: 11px; + text-transform: uppercase; + letter-spacing: 0.03em; + color: var(--text-color-muted, #59636e); + margin-left: 6px; +} From 88c40889ec54a9975de2d8597fa0b2f76c4c6075 Mon Sep 17 00:00:00 2001 From: David Pine <7679720+IEvangelist@users.noreply.github.com> Date: Mon, 29 Jun 2026 16:02:15 -0500 Subject: [PATCH 02/21] Fix og-preview canvas dark-mode theming and live panel title - Derive chrome surface colors from the guaranteed --text-color-default token via color-mix (with neutral translucent fallbacks) so the URL input, raw-metadata tables, and borders adapt to dark mode instead of using light-only fallback tokens. - Refresh the host panel title whenever a new URL is loaded by re-opening the canvas instance (guarded against reload loops), and update the iframe document.title. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .github/extensions/og-preview/extension.mjs | 66 ++++++++++++++++++--- .github/extensions/og-preview/ui/app.js | 3 + .github/extensions/og-preview/ui/styles.css | 25 +++++--- 3 files changed, 80 insertions(+), 14 deletions(-) diff --git a/.github/extensions/og-preview/extension.mjs b/.github/extensions/og-preview/extension.mjs index 02ff54214..ebc48da25 100644 --- a/.github/extensions/og-preview/extension.mjs +++ b/.github/extensions/og-preview/extension.mjs @@ -26,7 +26,7 @@ const CONTENT_TYPES = { ".js": "text/javascript; charset=utf-8", }; -// instanceId -> { server, url, currentUrl, clients:Set } +// instanceId -> { instanceId, server, url, currentUrl, titleKey, clients:Set } const instances = new Map(); let sessionRef = null; @@ -73,6 +73,48 @@ function sendJson(res, status, obj) { res.end(JSON.stringify(obj)); } +/** Human-readable panel title for the host chrome (scheme stripped for brevity). */ +function displayTitle(url) { + if (!url) return "OpenGraph Preview"; + try { + const u = new URL(url); + const path = u.pathname === "/" ? "" : u.pathname.replace(/\/+$/, ""); + return `OG · ${u.host}${path}${u.search}`; + } catch { + return `OG · ${url}`; + } +} + +/** Canonical comparison key so trailing-slash / case differences don't loop. */ +function titleKey(url) { + try { + const u = new URL(normalizeUrl(url)); + return `${u.protocol}//${u.host}${u.pathname.replace(/\/+$/, "")}${u.search}`.toLowerCase(); + } catch { + return String(url || "").trim().toLowerCase(); + } +} + +// Re-opening the same instance is the only SDK path to refresh the host panel +// title. Guard on a canonical key so it fires at most once per distinct URL and +// never feedback-loops (re-open reloads the iframe -> /api/fetch -> here again). +async function syncTitle(entry, url) { + if (!entry || !url) return; + const key = titleKey(url); + if (key === entry.titleKey) return; + entry.titleKey = key; + entry.currentUrl = url; + try { + await sessionRef?.rpc?.canvas?.open({ + canvasId: "og-preview", + instanceId: entry.instanceId, + input: { url }, + }); + } catch (err) { + log(`Title sync skipped: ${err && err.message ? err.message : err}`, "warning"); + } +} + function broadcast(entry, payload) { const data = `data: ${JSON.stringify(payload)}\n\n`; for (const client of entry.clients) { @@ -103,9 +145,6 @@ async function handleRequest(entry, req, res) { }); res.write(": connected\n\n"); entry.clients.add(res); - if (entry.currentUrl) { - res.write(`data: ${JSON.stringify({ type: "load", url: entry.currentUrl })}\n\n`); - } req.on("close", () => entry.clients.delete(res)); return; } @@ -116,7 +155,11 @@ async function handleRequest(entry, req, res) { try { const data = await loadMetadata(u); entry.currentUrl = data.requestedUrl; - return sendJson(res, 200, data); + sendJson(res, 200, data); + // Refresh the host panel title to the resolved URL (fire-and-forget; + // guarded against loops by syncTitle). + syncTitle(entry, data.requestedUrl).catch(() => {}); + return; } catch (err) { return sendJson(res, 200, { error: err.message }); } @@ -145,7 +188,14 @@ async function handleRequest(entry, req, res) { } async function startServer(instanceId, currentUrl) { - const entry = { server: null, url: "", currentUrl: currentUrl || "", clients: new Set() }; + const entry = { + instanceId, + server: null, + url: "", + currentUrl: currentUrl || "", + titleKey: currentUrl ? titleKey(currentUrl) : "", + clients: new Set(), + }; const server = createServer((req, res) => { Promise.resolve(handleRequest(entry, req, res)).catch((err) => { if (!res.headersSent) res.statusCode = 500; @@ -202,6 +252,7 @@ const ogCanvas = createCanvas({ const data = await loadMetadata(url); entry.currentUrl = data.requestedUrl; broadcast(entry, { type: "load", url: data.requestedUrl }); + syncTitle(entry, data.requestedUrl).catch(() => {}); return { requestedUrl: data.requestedUrl, resolved: data.resolved }; }, }, @@ -237,9 +288,10 @@ const ogCanvas = createCanvas({ entry.currentUrl = inputUrl; broadcast(entry, { type: "load", url: inputUrl }); } + if (inputUrl) entry.titleKey = titleKey(inputUrl); log(`OpenGraph Preview canvas opened (${ctx.instanceId}).`); return { - title: entry.currentUrl ? `OG · ${entry.currentUrl}` : "OpenGraph Preview", + title: displayTitle(entry.currentUrl), status: entry.currentUrl ? "Loaded" : "Ready", url: instanceUrl(entry), }; diff --git a/.github/extensions/og-preview/ui/app.js b/.github/extensions/og-preview/ui/app.js index 708157c7a..47ca3ab67 100644 --- a/.github/extensions/og-preview/ui/app.js +++ b/.github/extensions/og-preview/ui/app.js @@ -243,6 +243,9 @@ async function load(rawUrl) { if (data.resolved.url || data.requestedUrl) { input.value = data.requestedUrl || url; } + document.title = data.requestedUrl + ? `OG · ${(data.resolved && data.resolved.hostname) || data.requestedUrl}` + : "OpenGraph Preview"; renderPreviews(data); renderRaw(data); renderDiagnostics(data); diff --git a/.github/extensions/og-preview/ui/styles.css b/.github/extensions/og-preview/ui/styles.css index ef16039c1..89e86e39e 100644 --- a/.github/extensions/og-preview/ui/styles.css +++ b/.github/extensions/og-preview/ui/styles.css @@ -1,6 +1,17 @@ :root { --card-radius: 12px; --gap: 16px; + /* Theme-adaptive neutral surfaces. Derived from the guaranteed + --text-color-default token so they invert correctly in dark mode, where + undocumented *-inset / *-muted tokens may be undefined and fall back to + light values. The first declaration is a neutral translucent fallback for + engines without color-mix; the second refines it when color-mix exists. */ + --surface-inset: rgba(140, 149, 159, 0.12); + --surface-inset: color-mix(in srgb, var(--text-color-default, #1f2328) 6%, transparent); + --surface-muted: rgba(140, 149, 159, 0.18); + --surface-muted: color-mix(in srgb, var(--text-color-default, #1f2328) 10%, transparent); + --border-soft: rgba(140, 149, 159, 0.32); + --border-soft: color-mix(in srgb, var(--text-color-default, #1f2328) 16%, transparent); } * { @@ -30,7 +41,7 @@ body { code { font-family: var(--font-mono, "SFMono-Regular", Consolas, "Liberation Mono", monospace); font-size: var(--text-code-inline, 12px); - background: var(--background-color-muted, rgba(129, 139, 152, 0.12)); + background: var(--surface-muted); padding: 1px 5px; border-radius: 5px; } @@ -57,7 +68,7 @@ code { padding: 8px 12px; border-radius: 8px; border: 1px solid var(--border-color-default, #d1d9e0); - background: var(--background-color-inset, #f6f8fa); + background: var(--surface-inset); color: inherit; font-size: 14px; font-family: var(--font-mono, monospace); @@ -82,7 +93,7 @@ code { } .btn:hover { - background: var(--background-color-muted, rgba(129, 139, 152, 0.12)); + background: var(--surface-muted); } .btn.primary { @@ -143,7 +154,7 @@ code { } .status.loading { - background: var(--background-color-inset, #f6f8fa); + background: var(--surface-inset); } .status.error { @@ -449,7 +460,7 @@ table.kv { } table.kv td { - border: 1px solid var(--border-color-muted, #e5e9ed); + border: 1px solid var(--border-soft); padding: 6px 10px; vertical-align: top; word-break: break-word; @@ -459,7 +470,7 @@ table.kv td.k { width: 34%; font-family: var(--font-mono, monospace); color: var(--text-color-default, #1f2328); - background: var(--background-color-inset, #f6f8fa); + background: var(--surface-inset); white-space: nowrap; } @@ -478,7 +489,7 @@ table.kv td.v a:hover { align-items: flex-start; gap: 10px; padding: 10px 0; - border-bottom: 1px solid var(--border-color-muted, #e5e9ed); + border-bottom: 1px solid var(--border-soft); } .diag-mark { From 6da32d567fbff3fe81bc697210922b6652d5868a Mon Sep 17 00:00:00 2001 From: David Pine <7679720+IEvangelist@users.noreply.github.com> Date: Mon, 29 Jun 2026 21:20:48 -0500 Subject: [PATCH 03/21] Redesign og-preview canvas UX to match the app - Rebuild the chrome on documented app theme tokens with shadcn-style controls (soft radii, ghost/primary buttons, segmented tabs) and a single on-theme accent, so the canvas is indistinguishable from the app in light and dark. - Add an open_og_preview agent tool that opens/focuses the canvas (with an optional URL) so it can be summoned on command. - Add a collapsible page-info footer (final URL, HTTP status, tag count, diagnostics summary) with a remembered open/closed state. - Add an aspire.dev quick-example chip and auto-complete the URL scheme (https://, or http:// for localhost). - Add per-value copy buttons in the raw-metadata view. - Add realistic skeleton loaders that mirror the happy-path layout, with shimmer and View-Transition cross-fades, honoring prefers-reduced-motion. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .github/extensions/og-preview/README.md | 24 +- .github/extensions/og-preview/extension.mjs | 56 +- .github/extensions/og-preview/ui/app.js | 291 +++++++- .github/extensions/og-preview/ui/index.html | 97 ++- .github/extensions/og-preview/ui/styles.css | 729 ++++++++++++++++++-- 5 files changed, 1079 insertions(+), 118 deletions(-) diff --git a/.github/extensions/og-preview/README.md b/.github/extensions/og-preview/README.md index 44363cccf..fac3d789d 100644 --- a/.github/extensions/og-preview/README.md +++ b/.github/extensions/og-preview/README.md @@ -9,8 +9,17 @@ platforms, alongside a raw OpenGraph metadata view and a diagnostics checklist. - **Platform previews** — OpenGraph/Facebook, X (Twitter, both `summary` and `summary_large_image` layouts), LinkedIn, Slack, and Discord. - **Raw metadata** — every `og:*`, `twitter:*`, and other `` tag, grouped, - with a one-click **Copy JSON**. + with per-value quick **copy buttons** and a one-click **Copy JSON**. - **Diagnostics** — checks for the required/recommended OpenGraph tags. +- **Collapsible page-info footer** — final URL, HTTP status, tag count, and + diagnostics summary; expanded/collapsed state is remembered. +- **Quick examples** — one-tap chip to preview `aspire.dev`. +- **Auto scheme** — bare domains are completed automatically (`https://`, or + `http://` for localhost). +- **Native look & feel** — chrome is built on the documented app theme tokens + (shadcn-flavored controls, on-theme accent) and adapts to light/dark. +- **Loading UX** — shaped skeletons that mirror the real layout, shimmer, and + View-Transition cross-fades (respecting `prefers-reduced-motion`). - **localhost support** — fetches are made by the extension process over plain `http`/`https`, so loopback URLs work. - **Image proxy fallback** — preview images that block hotlinking are retried @@ -31,12 +40,15 @@ port) that serves the static UI from `ui/` and a JSON API: The target page is fetched and parsed server-side (no external dependencies), which sidesteps browser CORS and lets it reach `localhost`. -## Agent actions +## Agent actions & tools -- **`preview_url`** `{ url }` — load a URL into the open canvas and return its - resolved preview fields. -- **`get_metadata`** `{ url }` — fetch + parse a URL and return all raw metadata - as JSON, without opening the canvas. +- **`open_og_preview`** `{ url?, instanceId? }` *(tool)* — open or focus the + canvas in the side panel, optionally loading a URL immediately. Lets the agent + bring up the preview on command (e.g. "open the OG preview for aspire.dev"). +- **`preview_url`** `{ url }` *(canvas action)* — load a URL into the open canvas + and return its resolved preview fields. +- **`get_metadata`** `{ url }` *(canvas action)* — fetch + parse a URL and return + all raw metadata as JSON, without opening the canvas. ## Files diff --git a/.github/extensions/og-preview/extension.mjs b/.github/extensions/og-preview/extension.mjs index ebc48da25..9dfb1b639 100644 --- a/.github/extensions/og-preview/extension.mjs +++ b/.github/extensions/og-preview/extension.mjs @@ -29,6 +29,10 @@ const CONTENT_TYPES = { // instanceId -> { instanceId, server, url, currentUrl, titleKey, clients:Set } const instances = new Map(); +// Default panel handle used when the agent opens the canvas via the +// `open_og_preview` tool without specifying one. +const DEFAULT_INSTANCE = "og-main"; + let sessionRef = null; function log(message, level = "info") { try { @@ -311,4 +315,54 @@ const ogCanvas = createCanvas({ }, }); -sessionRef = await joinSession({ canvases: [ogCanvas] }); +// Agent-facing tool so the canvas can be opened "on command" (e.g. the user +// says "open the OG preview" or "show how aspire.dev unfurls"). Tool names must +// be globally unique across loaded extensions. +const agentTools = [ + { + name: "open_og_preview", + description: + "Open (or focus) the OpenGraph Preview canvas in the side panel, optionally loading a URL immediately. Supports localhost. Use whenever the user asks to open/show/add the OpenGraph (OG) preview, or to preview how a URL unfurls on social platforms.", + parameters: { + type: "object", + properties: { + url: { + type: "string", + description: + "Optional URL to load immediately. Scheme is optional — https:// is assumed (http:// for localhost).", + }, + instanceId: { + type: "string", + description: + "Optional panel handle (defaults to 'og-main'). Reuse the same value to refocus the same panel; pass a new value to open an additional panel.", + }, + }, + additionalProperties: false, + }, + handler: async (args) => { + const instanceId = + (args && typeof args.instanceId === "string" && args.instanceId.trim()) || + DEFAULT_INSTANCE; + const url = args && typeof args.url === "string" ? args.url.trim() : ""; + try { + await sessionRef?.rpc?.canvas?.open({ + canvasId: "og-preview", + instanceId, + input: url ? { url } : {}, + }); + } catch (err) { + return { + textResultForLlm: `Failed to open the OpenGraph Preview canvas: ${ + err && err.message ? err.message : err + }`, + resultType: "failure", + }; + } + return url + ? `Opened the OpenGraph Preview canvas (panel "${instanceId}") and started loading ${url}.` + : `Opened the OpenGraph Preview canvas (panel "${instanceId}"). Enter a URL in the panel to preview it.`; + }, + }, +]; + +sessionRef = await joinSession({ canvases: [ogCanvas], tools: agentTools }); diff --git a/.github/extensions/og-preview/ui/app.js b/.github/extensions/og-preview/ui/app.js index 47ca3ab67..8432f0d4b 100644 --- a/.github/extensions/og-preview/ui/app.js +++ b/.github/extensions/og-preview/ui/app.js @@ -3,6 +3,8 @@ const TRANSPARENT = "data:image/gif;base64,R0lGODlhAQABAAAAACH5BAEKAAEALAAAAAABAAEAAAICTAEAOw=="; +const FOOTER_KEY = "og-preview:footer-open"; + const $ = (sel) => document.querySelector(sel); const input = $("#url-input"); const statusEl = $("#status"); @@ -30,6 +32,153 @@ function prettyDomain(host) { return String(host || "").replace(/^www\./i, ""); } +/* ---------------- Motion / view transitions ---------------- */ + +const reduceMotion = window.matchMedia + ? window.matchMedia("(prefers-reduced-motion: reduce)") + : { matches: false }; + +/** Run a DOM mutation inside a View Transition when supported (and motion is + * allowed), otherwise apply it synchronously. */ +function withTransition(mutate) { + if (document.startViewTransition && !reduceMotion.matches) { + document.startViewTransition(() => mutate()); + } else { + mutate(); + } +} + +/* ---------------- Skeletons ---------------- + Mirror the real happy-path shapes so the layout doesn't jump when data lands. */ + +function sk(cls, extra) { + return el("div", { class: `skeleton ${cls}`, style: extra }); +} + +function skMeta(lines) { + return el( + "div", + { class: "sk-meta" }, + lines.map((w, i) => + sk(`sk-line${i === 0 ? " lg" : ""}`, `width:${w}`), + ), + ); +} + +function skPreviewCard(opts) { + const card = el("div", { class: `sk-card${opts && opts.compact ? " compact" : ""}` }, [ + sk("sk-img"), + skMeta(opts && opts.lines ? opts.lines : ["40%", "85%", "60%"]), + ]); + return card; +} + +function skLabeled(name) { + return el("div", { class: "preview" }, [ + el("div", { class: "preview-label" }, [sk("sk-dot"), name]), + skPreviewCard(name === "X · Twitter" ? { lines: ["50%", "80%"] } : {}), + ]); +} + +function skeletonPreviews() { + const grid = $("#previews"); + grid.classList.remove("enter"); + grid.replaceChildren( + skLabeled("OpenGraph · Facebook"), + skLabeled("X · Twitter"), + skLabeled("LinkedIn"), + skLabeled("Slack"), + skLabeled("Discord"), + ); +} + +function skRawGroup(rows) { + const list = [el("div", { class: "sk-rawhead" }, [sk("sk-line sm", "width:120px")])]; + for (let i = 0; i < rows; i += 1) { + list.push( + el("div", { class: "sk-rawrow" }, [ + sk("sk-line", `width:${60 + ((i * 13) % 30)}%`), + sk("sk-line", `width:${70 + ((i * 17) % 25)}%`), + ]), + ); + } + return el("div", { class: "sk-rawgroup" }, list); +} + +function skeletonRaw() { + $("#raw-summary").textContent = "Reading meta tags…"; + $("#raw").replaceChildren(skRawGroup(5), skRawGroup(4)); +} + +function skeletonDiagnostics() { + const rows = []; + for (let i = 0; i < 6; i += 1) { + rows.push( + el("div", { class: "sk-diagrow" }, [ + sk("sk-circle"), + el("div", { class: "sk-lines" }, [ + sk("sk-line", "width:30%"), + sk("sk-line sm", `width:${60 + ((i * 11) % 30)}%`), + ]), + ]), + ); + } + $("#diagnostics").replaceChildren(el("div", { class: "sk-diaglist" }, rows)); +} + +function renderSkeleton() { + skeletonPreviews(); + skeletonRaw(); + skeletonDiagnostics(); +} + +/** Auto-include a scheme: http:// for localhost/loopback, https:// otherwise. */ +function withScheme(raw) { + let v = (raw || "").trim(); + if (!v || v === "https://" || v === "http://") return ""; + if (/^https?:\/\//i.test(v)) return v; + v = v.replace(/^\/+/, ""); + const isLocal = /^(localhost|127\.0\.0\.1|0\.0\.0\.0|\[::1\]|[^/]+\.local)(:|\/|$)/i.test(v); + return (isLocal ? "http://" : "https://") + v; +} + +/* ---------------- Clipboard ---------------- */ + +async function copyText(text, btn) { + try { + await navigator.clipboard.writeText(text); + if (btn) { + btn.classList.add("copied"); + const prev = btn.getAttribute("aria-label") || "Copy"; + btn.setAttribute("aria-label", "Copied"); + setTimeout(() => { + btn.classList.remove("copied"); + btn.setAttribute("aria-label", prev); + }, 1100); + } + return true; + } catch { + return false; + } +} + +function copyButton(text, label) { + const btn = el("button", { + class: "copy-btn", + type: "button", + title: label || "Copy value", + "aria-label": label || "Copy value", + }); + btn.innerHTML = + ''; + btn.addEventListener("click", (e) => { + e.preventDefault(); + e.stopPropagation(); + copyText(text, btn); + }); + return btn; +} + /** Image element with direct -> proxy -> placeholder fallback chain. */ function makeImage(url, className) { if (!url) return el("div", { class: className }); @@ -137,17 +286,23 @@ function renderPreviews(data) { labeledCard("Slack", "#4a154b", slackCard(d, domain)), labeledCard("Discord", "#5865f2", discordCard(d, domain)), ); + // Retrigger the staggered entrance animation. + grid.classList.remove("enter"); + void grid.offsetWidth; + grid.classList.add("enter"); } /* ---------------- Raw ---------------- */ function valueCell(value) { const td = el("td", { class: "v" }); + const text = el("span", { class: "v-text" }); if (/^https?:\/\//i.test(value)) { - td.appendChild(el("a", { href: value, target: "_blank", rel: "noreferrer", text: value })); + text.appendChild(el("a", { href: value, target: "_blank", rel: "noreferrer", text: value })); } else { - td.textContent = value; + text.textContent = value; } + td.appendChild(el("div", { class: "v-row" }, [text, copyButton(value, "Copy value")])); return td; } @@ -189,6 +344,16 @@ function renderRaw(data) { /* ---------------- Diagnostics ---------------- */ +function diagnosticCounts(diagnostics) { + const counts = { ok: 0, warn: 0, req: 0 }; + for (const c of diagnostics || []) { + if (c.ok) counts.ok += 1; + else if (c.level === "required") counts.req += 1; + else counts.warn += 1; + } + return counts; +} + function renderDiagnostics(data) { const host = $("#diagnostics"); host.replaceChildren( @@ -216,6 +381,65 @@ function renderDiagnostics(data) { ); } +/* ---------------- Footer (page info) ---------------- */ + +function fact(label, valueNode) { + return el("div", { class: "fact" }, [ + el("div", { class: "fact-label", text: label }), + el("div", { class: "fact-value" }, [valueNode]), + ]); +} + +function statusPill(httpStatus) { + const cls = !httpStatus ? "warn" : httpStatus < 300 ? "ok" : httpStatus < 400 ? "warn" : "req"; + return el("span", { class: `pill ${cls}`, text: httpStatus ? String(httpStatus) : "—" }); +} + +function renderFooter(data) { + const counts = diagnosticCounts(data.diagnostics); + const summary = $("#footer-summary"); + summary.textContent = `${data.requestedUrl} · HTTP ${data.httpStatus || "?"} · ${ + data.tagCount + } tags`; + + const urlNode = el("a", { + href: data.requestedUrl, + target: "_blank", + rel: "noreferrer", + text: data.requestedUrl, + }); + + const diagNode = el("span", null, [ + el("span", { class: "pill ok", text: `${counts.ok} ok` }), + " ", + counts.warn ? el("span", { class: "pill warn", text: `${counts.warn} warn` }) : null, + counts.warn ? " " : null, + counts.req ? el("span", { class: "pill req", text: `${counts.req} missing` }) : null, + ]); + + const body = $("#footer-body"); + body.replaceChildren( + fact("Final URL", el("span", { class: "v-row" }, [urlNode, copyButton(data.requestedUrl, "Copy URL")])), + fact("HTTP status", statusPill(data.httpStatus)), + fact("Meta tags", el("span", { text: String(data.tagCount) })), + fact("Diagnostics", diagNode), + ); +} + +function setFooterOpen(open) { + const footer = $("#footer"); + const toggle = $("#footer-toggle"); + const body = $("#footer-body"); + footer.dataset.collapsed = open ? "false" : "true"; + toggle.setAttribute("aria-expanded", open ? "true" : "false"); + body.hidden = !open; + try { + localStorage.setItem(FOOTER_KEY, open ? "1" : "0"); + } catch { + /* storage unavailable */ + } +} + /* ---------------- Status + load ---------------- */ function setStatus(kind, message) { @@ -229,10 +453,14 @@ function setStatus(kind, message) { } async function load(rawUrl) { - const url = (rawUrl || "").trim(); + const url = withScheme(rawUrl); if (!url) return; + input.value = url; document.body.classList.add("has-data", "is-busy"); setStatus("loading", `Fetching ${url} …`); + $("#footer-summary").textContent = `Loading ${url} …`; + // Show realistic shaped skeletons immediately so the layout is stable. + renderSkeleton(); try { const res = await fetch("/api/fetch?u=" + encodeURIComponent(url)); const data = await res.json(); @@ -246,12 +474,17 @@ async function load(rawUrl) { document.title = data.requestedUrl ? `OG · ${(data.resolved && data.resolved.hostname) || data.requestedUrl}` : "OpenGraph Preview"; - renderPreviews(data); - renderRaw(data); - renderDiagnostics(data); + // Cross-fade skeletons -> content with a View Transition. + withTransition(() => { + renderPreviews(data); + renderRaw(data); + renderDiagnostics(data); + renderFooter(data); + }); setStatus(null); } catch (err) { setStatus("error", `Couldn't load metadata: ${err.message}`); + $("#footer-summary").textContent = `Failed to load ${url}`; } finally { document.body.classList.remove("is-busy"); } @@ -265,15 +498,38 @@ $("#url-form").addEventListener("submit", (e) => { }); $("#refresh").addEventListener("click", () => load(input.value)); +// Keep a scheme prefilled so the user never has to type it. +input.addEventListener("focus", () => { + if (!input.value.trim()) input.value = "https://"; +}); +input.addEventListener("blur", () => { + if (input.value.trim() === "https://" || input.value.trim() === "http://") input.value = "https://"; +}); + +document.querySelectorAll("[data-example]").forEach((btn) => { + btn.addEventListener("click", () => { + const url = btn.getAttribute("data-example"); + input.value = url; + load(url); + }); +}); + document.querySelectorAll(".tab").forEach((tab) => { tab.addEventListener("click", () => { - document.querySelectorAll(".tab").forEach((t) => t.classList.remove("active")); - document.querySelectorAll(".panel").forEach((p) => p.classList.remove("active")); - tab.classList.add("active"); - $("#panel-" + tab.dataset.tab).classList.add("active"); + if (tab.classList.contains("active")) return; + withTransition(() => { + document.querySelectorAll(".tab").forEach((t) => t.classList.remove("active")); + document.querySelectorAll(".panel").forEach((p) => p.classList.remove("active")); + tab.classList.add("active"); + $("#panel-" + tab.dataset.tab).classList.add("active"); + }); }); }); +$("#footer-toggle").addEventListener("click", () => { + setFooterOpen($("#footer").dataset.collapsed === "true"); +}); + $("#copy-json").addEventListener("click", async () => { if (!lastData) return; const payload = JSON.stringify( @@ -286,17 +542,24 @@ $("#copy-json").addEventListener("click", async () => { null, 2, ); - try { - await navigator.clipboard.writeText(payload); - const btn = $("#copy-json"); + const btn = $("#copy-json"); + const ok = await copyText(payload); + if (ok) { const old = btn.textContent; btn.textContent = "Copied!"; setTimeout(() => (btn.textContent = old), 1200); - } catch { + } else { setStatus("error", "Clipboard blocked — JSON is also returned by the get_metadata action."); } }); +// Restore footer open/closed preference. +try { + if (localStorage.getItem(FOOTER_KEY) === "1") setFooterOpen(true); +} catch { + /* storage unavailable */ +} + // Server-pushed loads (agent invoking the preview_url action). try { const es = new EventSource("/events"); diff --git a/.github/extensions/og-preview/ui/index.html b/.github/extensions/og-preview/ui/index.html index a48995504..fe982b0a3 100644 --- a/.github/extensions/og-preview/ui/index.html +++ b/.github/extensions/og-preview/ui/index.html @@ -9,24 +9,44 @@
- - - +
- +
+ +
+ Try + +
+
@@ -38,23 +58,50 @@
- +
+ +
+ +

Preview OpenGraph metadata

+

+ Enter a URL above to see how it unfurls on social platforms. + Local dev servers like localhost:3000 work too. +

+
+ +
+
-
-
🔎
-

Preview OpenGraph metadata

-

- Enter a URL above to see how it unfurls on social platforms. - Local dev servers like http://localhost:3000 work too. -

-
+
+ + +
diff --git a/.github/extensions/og-preview/ui/styles.css b/.github/extensions/og-preview/ui/styles.css index 89e86e39e..ab7fbaae8 100644 --- a/.github/extensions/og-preview/ui/styles.css +++ b/.github/extensions/og-preview/ui/styles.css @@ -1,17 +1,39 @@ +/* ========================================================================== + OpenGraph Preview — canvas chrome + Styled to feel native to the Copilot app: documented semantic theme tokens, + a shadcn-flavored control system (soft radii, hairline borders, ghost/ + primary buttons, segmented tabs), and a single on-theme accent. + The platform PREVIEW CARDS (.fb/.x/.li/.slack/.discord) are intentionally + NOT themed — they mimic the real products and keep their brand colors. + ========================================================================== */ + :root { - --card-radius: 12px; - --gap: 16px; + /* Shape */ + --radius: 6px; + --radius-lg: 8px; + --radius-xl: 10px; + + /* On-theme accent. Prefer the app's focus/accent token; fall back to the + documented blue true-color, then a literal. */ + --accent: var(--color-focus-outline, var(--true-color-blue, #0969da)); + --accent-fg: var(--color-white, #ffffff); + /* Theme-adaptive neutral surfaces. Derived from the guaranteed --text-color-default token so they invert correctly in dark mode, where undocumented *-inset / *-muted tokens may be undefined and fall back to light values. The first declaration is a neutral translucent fallback for engines without color-mix; the second refines it when color-mix exists. */ - --surface-inset: rgba(140, 149, 159, 0.12); - --surface-inset: color-mix(in srgb, var(--text-color-default, #1f2328) 6%, transparent); - --surface-muted: rgba(140, 149, 159, 0.18); - --surface-muted: color-mix(in srgb, var(--text-color-default, #1f2328) 10%, transparent); - --border-soft: rgba(140, 149, 159, 0.32); - --border-soft: color-mix(in srgb, var(--text-color-default, #1f2328) 16%, transparent); + --surface-inset: rgba(140, 149, 159, 0.1); + --surface-inset: color-mix(in srgb, var(--text-color-default, #1f2328) 5%, transparent); + --surface-muted: rgba(140, 149, 159, 0.16); + --surface-muted: color-mix(in srgb, var(--text-color-default, #1f2328) 9%, transparent); + --surface-raised: var(--background-color-default, #ffffff); + --border-soft: rgba(140, 149, 159, 0.3); + --border-soft: color-mix(in srgb, var(--text-color-default, #1f2328) 14%, transparent); + --accent-soft: color-mix(in srgb, var(--accent) 14%, transparent); + + --shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.08), 0 1px 1px rgba(0, 0, 0, 0.04); + --shadow-pop: 0 1px 1px rgba(0, 0, 0, 0.04), 0 4px 12px rgba(0, 0, 0, 0.12); } * { @@ -46,12 +68,85 @@ code { border-radius: 5px; } +/* ---------- Buttons (shadcn-style) ---------- */ +.btn { + display: inline-flex; + align-items: center; + justify-content: center; + gap: 6px; + height: 32px; + padding: 0 12px; + border-radius: var(--radius); + border: 1px solid transparent; + background: transparent; + color: inherit; + font-size: 14px; + font-weight: var(--font-weight-semibold, 600); + font-family: inherit; + line-height: 1; + cursor: pointer; + white-space: nowrap; + transition: background-color 0.12s ease, border-color 0.12s ease, color 0.12s ease, + box-shadow 0.12s ease, filter 0.12s ease; +} + +.btn:focus-visible { + outline: 2px solid var(--accent); + outline-offset: 2px; +} + +.btn-primary { + background: var(--accent); + border-color: transparent; + color: var(--accent-fg); + box-shadow: var(--shadow-sm); +} + +.btn-primary:hover { + filter: brightness(1.08); +} + +.btn-outline { + border-color: var(--border-color-default, #d1d9e0); + background: var(--surface-raised); +} + +.btn-outline:hover { + background: var(--surface-muted); +} + +.btn-ghost { + background: transparent; + border-color: transparent; + color: var(--text-color-default, #1f2328); +} + +.btn-ghost:hover { + background: var(--surface-muted); +} + +.btn-icon { + width: 32px; + padding: 0; + color: var(--text-color-muted, #59636e); +} + +.btn-icon:hover { + color: var(--text-color-default, #1f2328); +} + +.btn-sm { + height: 28px; + padding: 0 10px; + font-size: 12px; +} + /* ---------- Toolbar ---------- */ .toolbar { position: sticky; top: 0; z-index: 5; - padding: 12px 16px 0; + padding: 12px 16px; background: var(--background-color-default, #ffffff); border-bottom: 1px solid var(--border-color-default, #d1d9e0); } @@ -62,101 +157,164 @@ code { align-items: center; } +.input-wrap { + position: relative; + flex: 1; + min-width: 0; + display: flex; + align-items: center; +} + +.input-icon { + position: absolute; + left: 10px; + color: var(--text-color-muted, #59636e); + pointer-events: none; +} + .url-input { flex: 1; min-width: 0; - padding: 8px 12px; - border-radius: 8px; + height: 32px; + padding: 0 12px 0 32px; + border-radius: var(--radius); border: 1px solid var(--border-color-default, #d1d9e0); background: var(--surface-inset); color: inherit; - font-size: 14px; + font-size: 13px; font-family: var(--font-mono, monospace); } -.url-input:focus { - outline: 2px solid var(--color-focus-outline, #0969da); - outline-offset: -1px; - border-color: transparent; +.url-input::placeholder { + color: var(--text-color-muted, #59636e); + opacity: 0.7; } -.btn { - border: 1px solid var(--border-color-default, #d1d9e0); +.url-input:focus { + outline: none; + border-color: var(--accent); + box-shadow: 0 0 0 3px var(--accent-soft); background: var(--background-color-default, #ffffff); - color: inherit; - padding: 8px 14px; - border-radius: 8px; - font-size: 14px; - font-weight: var(--font-weight-semibold, 600); - cursor: pointer; - white-space: nowrap; } -.btn:hover { - background: var(--surface-muted); +.toolbar-row { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + margin-top: 12px; } -.btn.primary { - background: var(--true-color-blue, #0969da); - border-color: var(--true-color-blue, #0969da); - color: var(--color-white, #ffffff); +/* Segmented tabs */ +.tabs { + display: inline-flex; + gap: 2px; + padding: 3px; + background: var(--surface-inset); + border: 1px solid var(--border-soft); + border-radius: var(--radius-lg); } -.btn.primary:hover { - filter: brightness(1.05); +.tab { + border: none; + background: none; + color: var(--text-color-muted, #59636e); + padding: 5px 12px; + border-radius: var(--radius); + font-size: 13px; + font-weight: var(--font-weight-semibold, 600); + font-family: inherit; + cursor: pointer; + transition: background-color 0.12s ease, color 0.12s ease, box-shadow 0.12s ease; } -.btn.icon { - padding: 8px 12px; - font-size: 16px; - line-height: 1; +.tab:hover { + color: var(--text-color-default, #1f2328); } -.btn.small { - padding: 4px 10px; - font-size: 12px; +.tab.active { + color: var(--text-color-default, #1f2328); + background: var(--surface-raised); + box-shadow: var(--shadow-sm); } -.tabs { +.tab:focus-visible { + outline: 2px solid var(--accent); + outline-offset: 1px; +} + +/* Example chips */ +.examples { display: flex; - gap: 4px; - margin-top: 10px; + align-items: center; + gap: 8px; + flex-shrink: 0; } -.tab { - border: none; - background: none; - color: var(--text-color-muted, #59636e); - padding: 8px 4px; - margin-right: 12px; - font-size: 14px; +.examples-label { + font-size: 12px; + text-transform: uppercase; + letter-spacing: 0.04em; +} + +.chip { + height: 26px; + padding: 0 10px; + border-radius: 999px; + border: 1px solid var(--border-color-default, #d1d9e0); + background: var(--surface-raised); + color: var(--text-color-default, #1f2328); + font-size: 12px; font-weight: var(--font-weight-semibold, 600); + font-family: var(--font-mono, monospace); cursor: pointer; - border-bottom: 2px solid transparent; + transition: background-color 0.12s ease, border-color 0.12s ease, color 0.12s ease; } -.tab:hover { +.chip:hover { + background: var(--accent-soft); + border-color: var(--accent); color: var(--text-color-default, #1f2328); } -.tab.active { - color: var(--text-color-default, #1f2328); - border-bottom-color: var(--true-color-orange, #fb8500); +.chip:focus-visible { + outline: 2px solid var(--accent); + outline-offset: 1px; } /* ---------- Status ---------- */ .status { margin: 12px 16px 0; padding: 10px 14px; - border-radius: 8px; + border-radius: var(--radius-lg); font-size: 13px; - border: 1px solid var(--border-color-default, #d1d9e0); + border: 1px solid var(--border-soft); + display: flex; + align-items: center; + gap: 8px; } .status.loading { background: var(--surface-inset); } +.status.loading::before { + content: ""; + width: 13px; + height: 13px; + border-radius: 50%; + border: 2px solid var(--border-color-default, #d1d9e0); + border-top-color: var(--accent); + animation: spin 0.7s linear infinite; + flex: 0 0 auto; +} + +@keyframes spin { + to { + transform: rotate(360deg); + } +} + .status.error { background: var(--true-color-red-muted, rgba(255, 129, 130, 0.15)); border-color: var(--true-color-red, #cf222e); @@ -168,6 +326,8 @@ code { flex: 1; overflow: auto; padding: 16px; + display: flex; + flex-direction: column; } .panel { @@ -181,21 +341,30 @@ code { .empty { margin: auto; text-align: center; - max-width: 420px; - padding: 48px 24px; + max-width: 440px; + padding: 40px 24px; } .empty-icon { - font-size: 40px; + color: var(--text-color-muted, #59636e); + opacity: 0.55; } .empty-title { font-size: var(--text-title-medium, 18px); font-weight: var(--font-weight-semibold, 600); - margin: 8px 0 4px; + margin: 10px 0 4px; } -.is-busy .empty { +.empty-actions { + margin-top: 18px; +} + +body.has-data .empty { + display: none; +} + +body.is-busy .empty { display: none; } @@ -431,19 +600,26 @@ code { align-items: center; justify-content: space-between; gap: 12px; - margin-bottom: 12px; + margin-bottom: 14px; } .raw-group { margin-bottom: 22px; + border: 1px solid var(--border-soft); + border-radius: var(--radius-lg); + overflow: hidden; + background: var(--surface-raised); } .raw-group h3 { - font-size: 13px; + font-size: 12px; text-transform: uppercase; letter-spacing: 0.04em; color: var(--text-color-muted, #59636e); - margin: 0 0 8px; + margin: 0; + padding: 9px 12px; + background: var(--surface-inset); + border-bottom: 1px solid var(--border-soft); display: flex; align-items: center; gap: 8px; @@ -459,23 +635,42 @@ table.kv { font-size: 13px; } +table.kv tr + tr td { + border-top: 1px solid var(--border-soft); +} + table.kv td { - border: 1px solid var(--border-soft); - padding: 6px 10px; + padding: 7px 12px; vertical-align: top; word-break: break-word; } table.kv td.k { - width: 34%; + width: 32%; font-family: var(--font-mono, monospace); - color: var(--text-color-default, #1f2328); + color: var(--text-color-muted, #59636e); background: var(--surface-inset); + border-right: 1px solid var(--border-soft); white-space: nowrap; } +table.kv td.v { + position: relative; +} + +.v-row { + display: flex; + align-items: flex-start; + gap: 8px; +} + +.v-text { + flex: 1; + min-width: 0; +} + table.kv td.v a { - color: var(--true-color-blue, #0969da); + color: var(--accent); text-decoration: none; } @@ -483,13 +678,61 @@ table.kv td.v a:hover { text-decoration: underline; } +/* Quick copy buttons on values */ +.copy-btn { + flex: 0 0 auto; + width: 24px; + height: 22px; + display: inline-flex; + align-items: center; + justify-content: center; + padding: 0; + border: 1px solid transparent; + border-radius: 5px; + background: transparent; + color: var(--text-color-muted, #59636e); + cursor: pointer; + opacity: 0; + transition: opacity 0.12s ease, background-color 0.12s ease, color 0.12s ease; +} + +.v-row:hover .copy-btn, +.copy-btn:focus-visible { + opacity: 1; +} + +.copy-btn:hover { + background: var(--surface-muted); + color: var(--text-color-default, #1f2328); +} + +.copy-btn:focus-visible { + outline: 2px solid var(--accent); + outline-offset: 1px; +} + +.copy-btn.copied { + opacity: 1; + color: var(--true-color-green, #1a7f37); +} + /* ---------- Diagnostics ---------- */ +#diagnostics { + border: 1px solid var(--border-soft); + border-radius: var(--radius-lg); + overflow: hidden; + background: var(--surface-raised); +} + .diag-item { display: flex; align-items: flex-start; gap: 10px; - padding: 10px 0; - border-bottom: 1px solid var(--border-soft); + padding: 11px 14px; +} + +.diag-item + .diag-item { + border-top: 1px solid var(--border-soft); } .diag-mark { @@ -526,3 +769,345 @@ table.kv td.v a:hover { color: var(--text-color-muted, #59636e); margin-left: 6px; } + +/* ---------- Collapsible footer (page info) ---------- */ +.footer { + flex: 0 0 auto; + background: var(--background-color-default, #ffffff); + border-top: 1px solid var(--border-color-default, #d1d9e0); +} + +.footer-bar { + width: 100%; + display: flex; + align-items: center; + gap: 8px; + padding: 8px 16px; + background: transparent; + border: none; + color: inherit; + font: inherit; + text-align: left; + cursor: pointer; +} + +.footer-bar:hover { + background: var(--surface-inset); +} + +.footer-bar:focus-visible { + outline: 2px solid var(--accent); + outline-offset: -2px; +} + +.footer-chevron { + color: var(--text-color-muted, #59636e); + transition: transform 0.15s ease; + flex: 0 0 auto; +} + +.footer:not([data-collapsed="true"]) .footer-chevron { + transform: rotate(90deg); +} + +.footer-summary { + flex: 1; + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + font-size: 12.5px; + color: var(--text-color-default, #1f2328); +} + +.footer-hint { + font-size: 11px; + text-transform: uppercase; + letter-spacing: 0.04em; + flex: 0 0 auto; +} + +.footer-body { + padding: 4px 16px 14px; + display: grid; + grid-template-columns: repeat(auto-fill, minmax(200px, 1fr)); + gap: 10px 18px; +} + +.fact { + min-width: 0; +} + +.fact-label { + font-size: 11px; + text-transform: uppercase; + letter-spacing: 0.04em; + color: var(--text-color-muted, #59636e); + margin-bottom: 2px; +} + +.fact-value { + font-size: 13px; + word-break: break-word; + display: flex; + align-items: center; + gap: 6px; +} + +.fact-value a { + color: var(--accent); + text-decoration: none; +} + +.fact-value a:hover { + text-decoration: underline; +} + +.pill { + display: inline-flex; + align-items: center; + gap: 4px; + padding: 1px 8px; + border-radius: 999px; + font-size: 12px; + font-weight: var(--font-weight-semibold, 600); + border: 1px solid var(--border-soft); +} + +.pill.ok { + color: var(--true-color-green, #1a7f37); + border-color: color-mix(in srgb, var(--true-color-green, #1a7f37) 40%, transparent); +} +.pill.warn { + color: var(--true-color-orange, #bc4c00); + border-color: color-mix(in srgb, var(--true-color-orange, #bc4c00) 40%, transparent); +} +.pill.req { + color: var(--true-color-red, #cf222e); + border-color: color-mix(in srgb, var(--true-color-red, #cf222e) 40%, transparent); +} + +/* ========================================================================== + Loading UX: skeletons, shimmer, entrance + view transitions + ========================================================================== */ + +/* Shimmering placeholder primitive. Derives from the text color so it adapts to + light/dark. Shapes below compose .skeleton with sizing helpers. */ +.skeleton { + position: relative; + overflow: hidden; + background: var(--surface-muted); + border-radius: var(--radius); +} + +.skeleton::after { + content: ""; + position: absolute; + inset: 0; + transform: translateX(-100%); + background: linear-gradient( + 90deg, + transparent, + color-mix(in srgb, var(--text-color-default, #1f2328) 10%, transparent), + transparent + ); + animation: shimmer 1.25s ease-in-out infinite; +} + +@keyframes shimmer { + 100% { + transform: translateX(100%); + } +} + +.sk-line { + height: 11px; + border-radius: 5px; +} + +.sk-line.lg { + height: 15px; +} + +.sk-line.sm { + height: 9px; +} + +.sk-img { + width: 100%; + aspect-ratio: 1.91 / 1; + border-radius: 0; +} + +.sk-dot { + width: 9px; + height: 9px; + border-radius: 50%; + flex: 0 0 auto; +} + +.sk-circle { + width: 20px; + height: 20px; + border-radius: 50%; + flex: 0 0 20px; +} + +/* Neutral, on-theme card frame used for every preview skeleton (we don't mimic + brand colors while loading — a calm neutral placeholder reads better). */ +.sk-card { + border: 1px solid var(--border-soft); + border-radius: var(--radius-lg); + overflow: hidden; + background: var(--surface-raised); +} + +.sk-card .sk-meta { + padding: 12px; + display: flex; + flex-direction: column; + gap: 8px; +} + +.sk-card.compact { + display: flex; + align-items: stretch; +} + +.sk-card.compact .sk-img { + width: 120px; + flex: 0 0 120px; + aspect-ratio: 1 / 1; +} + +.sk-card.compact .sk-meta { + flex: 1; + justify-content: center; +} + +/* Raw skeleton */ +.sk-rawgroup { + border: 1px solid var(--border-soft); + border-radius: var(--radius-lg); + overflow: hidden; + margin-bottom: 22px; + background: var(--surface-raised); +} + +.sk-rawhead { + padding: 11px 12px; + background: var(--surface-inset); + border-bottom: 1px solid var(--border-soft); +} + +.sk-rawrow { + display: grid; + grid-template-columns: 32% 1fr; + gap: 12px; + padding: 9px 12px; + align-items: center; +} + +.sk-rawrow + .sk-rawrow { + border-top: 1px solid var(--border-soft); +} + +/* Diagnostics skeleton */ +.sk-diaglist { + border: 1px solid var(--border-soft); + border-radius: var(--radius-lg); + overflow: hidden; + background: var(--surface-raised); +} + +.sk-diagrow { + display: flex; + align-items: flex-start; + gap: 10px; + padding: 11px 14px; +} + +.sk-diagrow + .sk-diagrow { + border-top: 1px solid var(--border-soft); +} + +.sk-diagrow .sk-lines { + flex: 1; + display: flex; + flex-direction: column; + gap: 7px; +} + +/* Staggered entrance for real preview cards (post-load). Skipped under reduced + motion and harmless alongside the view-transition root cross-fade. */ +.previews-grid.enter .preview { + animation: rise 0.34s cubic-bezier(0.22, 0.61, 0.36, 1) both; +} + +.previews-grid.enter .preview:nth-child(2) { + animation-delay: 0.05s; +} +.previews-grid.enter .preview:nth-child(3) { + animation-delay: 0.1s; +} +.previews-grid.enter .preview:nth-child(4) { + animation-delay: 0.15s; +} +.previews-grid.enter .preview:nth-child(5) { + animation-delay: 0.2s; +} + +@keyframes rise { + from { + opacity: 0; + transform: translateY(8px); + } + to { + opacity: 1; + transform: translateY(0); + } +} + +/* Footer body expand */ +.footer-body:not([hidden]) { + animation: footer-open 0.18s ease both; +} + +@keyframes footer-open { + from { + opacity: 0; + transform: translateY(-4px); + } + to { + opacity: 1; + transform: translateY(0); + } +} + +/* View Transitions: gentle cross-fade between skeleton and content, and between + tabs. The default root snapshot is animated; tune duration/easing here. */ +::view-transition-old(root), +::view-transition-new(root) { + animation-duration: 0.26s; + animation-timing-function: ease; +} + +@media (prefers-reduced-motion: reduce) { + .skeleton::after { + animation: none; + } + .previews-grid.enter .preview { + animation: none; + } + .footer-body:not([hidden]) { + animation: none; + } + .footer-chevron { + transition: none; + } + ::view-transition-old(root), + ::view-transition-new(root) { + animation: none; + } +} + From 285380c51e6d42b50b4e5f912109184ff43decbd Mon Sep 17 00:00:00 2001 From: David Pine <7679720+IEvangelist@users.noreply.github.com> Date: Mon, 29 Jun 2026 22:03:47 -0500 Subject: [PATCH 04/21] Polish og-preview: code hovercards, rich value formatting, handle/commit links - Add /api/raw route + interactive scrollable code/file hovercard - Rich raw-metadata value formatting (colors, numbers, dates, tokens, pills) - Link Twitter/X handles to x.com and recognized commit SHAs to GitHub - Compact page-info footer (stat strip + conditional canonical line) - Theme-aware brand preview cards (dark variants via body[data-mode]) - Unify hover dismissal (scroll/blur/Escape) and fix stuck loading banner Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .github/extensions/og-preview/extension.mjs | 37 ++ .github/extensions/og-preview/ui/app.js | 617 ++++++++++++++++++-- .github/extensions/og-preview/ui/styles.css | 490 ++++++++++++++-- 3 files changed, 1075 insertions(+), 69 deletions(-) diff --git a/.github/extensions/og-preview/extension.mjs b/.github/extensions/og-preview/extension.mjs index 9dfb1b639..e945316a1 100644 --- a/.github/extensions/og-preview/extension.mjs +++ b/.github/extensions/og-preview/extension.mjs @@ -187,6 +187,43 @@ async function handleRequest(entry, req, res) { } } + if (path === "/api/raw") { + const u = reqUrl.searchParams.get("u"); + if (!u) return sendJson(res, 400, { error: "Missing 'u' query parameter." }); + try { + const r = await fetchUrl(u, { + accept: "text/plain,text/markdown,application/json,text/*;q=0.9,*/*;q=0.5", + timeoutMs: 12000, + maxBytes: 1024 * 1024, + }); + const ct = r.contentType || ""; + if (/^(image|video|audio|font)\//i.test(ct)) { + return sendJson(res, 200, { error: `Not a text file (Content-Type: ${ct}).` }); + } + const MAX_CHARS = 200000; + let text = r.body.toString("utf8"); + // Reject content that is clearly binary (NUL byte in the sample). + if (text.slice(0, 4096).includes("\u0000")) { + return sendJson(res, 200, { error: "File appears to be binary." }); + } + let truncated = false; + if (text.length > MAX_CHARS) { + text = text.slice(0, MAX_CHARS); + truncated = true; + } + return sendJson(res, 200, { + url: r.url, + status: r.status, + contentType: ct, + bytes: r.body.length, + truncated, + text, + }); + } catch (err) { + return sendJson(res, 200, { error: err.message || "Couldn't load file preview." }); + } + } + res.statusCode = 404; res.end("Not found"); } diff --git a/.github/extensions/og-preview/ui/app.js b/.github/extensions/og-preview/ui/app.js index 8432f0d4b..acff6b4b4 100644 --- a/.github/extensions/og-preview/ui/app.js +++ b/.github/extensions/og-preview/ui/app.js @@ -196,6 +196,286 @@ function makeImage(url, className) { return img; } +/* ---------------- Image hover preview ---------------- + A single floating tooltip that previews an image asset and follows the + cursor while hovering an image-like value. Stylized with theme tokens. */ + +let imgTip = null; +let imgTipImg = null; +let imgTipMeta = null; + +function ensureImgTip() { + if (imgTip) return imgTip; + imgTipImg = el("img", { alt: "" }); + imgTipImg.referrerPolicy = "no-referrer"; + imgTipMeta = el("div", { class: "img-tip-meta" }); + imgTip = el("div", { class: "img-tip", hidden: "" }, [imgTipImg, imgTipMeta]); + document.body.appendChild(imgTip); + return imgTip; +} + +function positionImgTip(x, y) { + if (!imgTip || imgTip.hidden) return; + const pad = 16; + const w = imgTip.offsetWidth || 272; + const h = imgTip.offsetHeight || 200; + let left = x + pad; + let top = y + pad; + if (left + w + 8 > window.innerWidth) left = x - w - pad; + if (top + h + 8 > window.innerHeight) top = y - h - pad; + imgTip.style.left = Math.max(8, left) + "px"; + imgTip.style.top = Math.max(8, top) + "px"; +} + +function showImgTip(url, x, y) { + const real = url.startsWith("//") ? "https:" + url : url; + const tip = ensureImgTip(); + imgTipMeta.textContent = "Loading…"; + imgTipImg.dataset.stage = "direct"; + imgTipImg.onload = () => { + const { naturalWidth: nw, naturalHeight: nh } = imgTipImg; + imgTipMeta.textContent = nw && nh ? `${nw} × ${nh}` : ""; + }; + imgTipImg.onerror = () => { + if (imgTipImg.dataset.stage === "direct") { + imgTipImg.dataset.stage = "proxy"; + imgTipImg.src = "/api/img?u=" + encodeURIComponent(real); + } else { + imgTipMeta.textContent = "Preview unavailable"; + } + }; + imgTipImg.src = real; + tip.hidden = false; + positionImgTip(x, y); + requestAnimationFrame(() => tip.classList.add("visible")); +} + +function hideImgTip() { + if (!imgTip) return; + imgTip.classList.remove("visible"); + imgTip.hidden = true; + imgTipImg.removeAttribute("src"); +} + +function bindImageHover(node, url) { + node.addEventListener("mouseenter", (e) => showImgTip(url, e.clientX, e.clientY)); + node.addEventListener("mousemove", (e) => positionImgTip(e.clientX, e.clientY)); + node.addEventListener("mouseleave", hideImgTip); +} + +/* ---------------- Code / .mdx hover preview ---------------- + Anchored, interactive (scrollable) hovercard that fetches and renders the + source of a code/markdown file referenced by a value, so it can be explored + without leaving the canvas. Unlike the image tooltip it accepts pointer + events, so the user can move into it and scroll. */ + +const CODE_EXT_RE = + /\.(mdx?|markdown|jsx?|mjs|cjs|tsx?|json5?|jsonc|ya?ml|toml|ini|cfg|conf|env|css|scss|sass|less|html?|xml|rss|atom|sh|bash|zsh|fish|ps1|psm1|py|rb|go|rs|java|kt|kts|swift|c|h|hpp|cc|cpp|cxx|cs|php|sql|graphql|gql|proto|vue|svelte|astro|txt|text|log|lock|gradle|dockerfile|makefile|cmake)(\?|#|$)/i; + +const LANG_LABELS = { + mdx: "MDX", md: "Markdown", markdown: "Markdown", js: "JavaScript", mjs: "JavaScript", + cjs: "JavaScript", jsx: "JSX", ts: "TypeScript", tsx: "TSX", json: "JSON", json5: "JSON5", + jsonc: "JSON", yaml: "YAML", yml: "YAML", toml: "TOML", ini: "INI", cfg: "Config", + conf: "Config", env: "Env", css: "CSS", scss: "SCSS", sass: "Sass", less: "Less", + html: "HTML", htm: "HTML", xml: "XML", rss: "RSS", atom: "Atom", sh: "Shell", bash: "Shell", + zsh: "Shell", fish: "Shell", ps1: "PowerShell", psm1: "PowerShell", py: "Python", rb: "Ruby", + go: "Go", rs: "Rust", java: "Java", kt: "Kotlin", kts: "Kotlin", swift: "Swift", c: "C", + h: "C", hpp: "C++", cc: "C++", cpp: "C++", cxx: "C++", cs: "C#", php: "PHP", sql: "SQL", + graphql: "GraphQL", gql: "GraphQL", proto: "Protobuf", vue: "Vue", svelte: "Svelte", + astro: "Astro", txt: "Text", text: "Text", log: "Log", lock: "Lockfile", gradle: "Gradle", + dockerfile: "Dockerfile", makefile: "Makefile", cmake: "CMake", +}; + +function urlExt(url) { + const m = String(url || "").toLowerCase().match(/\.([a-z0-9]+)(?:[?#]|$)/); + return m ? m[1] : ""; +} + +function looksLikeCode(value) { + if (!/^(https?:)?\/\//i.test(value || "")) return false; + if (/\.svg(\?|#|$)/i.test(value)) return false; // SVG is previewed as an image + return CODE_EXT_RE.test(value); +} + +function codeLang(url) { + const ext = urlExt(url); + return LANG_LABELS[ext] || (ext ? ext.toUpperCase() : "Code"); +} + +function codeFileName(url) { + try { + const u = new URL(url.startsWith("//") ? "https:" + url : url); + return u.pathname.split("/").filter(Boolean).pop() || u.host; + } catch { + return url; + } +} + +function formatBytes(n) { + if (n == null) return ""; + if (n < 1024) return `${n} B`; + if (n < 1024 * 1024) return `${(n / 1024).toFixed(n < 10240 ? 1 : 0)} KB`; + return `${(n / 1048576).toFixed(1)} MB`; +} + +const codeCache = new Map(); // url -> payload | { error } +let codeCard = null; +let codeHead = null; +let codeBody = null; +let codeHideTimer = null; +let codeShowTimer = null; +let codeReqId = 0; + +function ensureCodeCard() { + if (codeCard) return codeCard; + codeHead = el("div", { class: "code-card-head" }); + codeBody = el("div", { class: "code-card-body" }); + codeCard = el("div", { class: "code-card", hidden: "" }, [codeHead, codeBody]); + codeCard.addEventListener("mouseenter", () => clearTimeout(codeHideTimer)); + codeCard.addEventListener("mouseleave", scheduleHideCode); + document.body.appendChild(codeCard); + return codeCard; +} + +function positionCodeCard(rect) { + if (!codeCard || codeCard.hidden) return; + const m = 8; + const w = codeCard.offsetWidth || 520; + const h = codeCard.offsetHeight || 320; + let left = rect.left; + if (left + w + m > window.innerWidth) left = window.innerWidth - w - m; + left = Math.max(m, left); + let top = rect.bottom + 6; + if (top + h + m > window.innerHeight) { + const above = rect.top - 6 - h; + top = above > m ? above : Math.max(m, window.innerHeight - h - m); + } + codeCard.style.left = left + "px"; + codeCard.style.top = top + "px"; +} + +function codeIconButton(cls, label, svg) { + const b = el("button", { class: cls, type: "button", title: label, "aria-label": label }); + b.innerHTML = svg; + return b; +} + +function renderCodeHeader(url, payload) { + const lang = el("span", { class: "code-lang", text: codeLang(url) }); + const name = el("span", { class: "code-name", text: codeFileName(url) }); + const meta = el("span", { + class: "code-meta muted", + text: payload && !payload.error + ? `${formatBytes(payload.bytes)}${payload.truncated ? " · truncated" : ""}` + : "", + }); + const open = el("a", { + class: "code-open", + href: url.startsWith("//") ? "https:" + url : url, + target: "_blank", + rel: "noreferrer", + title: "Open raw", + "aria-label": "Open raw", + }); + open.innerHTML = + ''; + const copy = codeIconButton( + "code-copy", + "Copy file", + '', + ); + if (payload && !payload.error) { + copy.addEventListener("click", (e) => { + e.preventDefault(); + e.stopPropagation(); + copyText(payload.text, copy); + }); + } else { + copy.disabled = true; + } + codeHead.replaceChildren(lang, name, meta, open, copy); +} + +function renderCodeBody(text, truncated) { + const MAX_LINES = 600; + const allLines = text.replace(/\n$/, "").split("\n"); + const lines = allLines.slice(0, MAX_LINES); + const wrap = el("div", { class: "code-lines" }); + const frag = document.createDocumentFragment(); + lines.forEach((ln, i) => { + frag.appendChild( + el("div", { class: "cl" }, [ + el("span", { class: "cl-n", text: String(i + 1) }), + el("span", { class: "cl-t", text: ln.length ? ln : " " }), + ]), + ); + }); + wrap.appendChild(frag); + codeBody.replaceChildren(el("div", { class: "code-scroll" }, [wrap])); + if (truncated || allLines.length > MAX_LINES) { + codeBody.appendChild( + el("div", { + class: "code-more muted", + text: `Showing ${lines.length} of ${allLines.length}${truncated ? "+" : ""} lines — open raw to see all.`, + }), + ); + } +} + +async function showCodeCard(url, node) { + hideImgTip(); + const card = ensureCodeCard(); + const token = ++codeReqId; + renderCodeHeader(url, null); + codeBody.replaceChildren(el("div", { class: "code-loading", text: "Loading…" })); + card.hidden = false; + positionCodeCard(node.getBoundingClientRect()); + requestAnimationFrame(() => card.classList.add("visible")); + + let payload = codeCache.get(url); + if (!payload) { + try { + const res = await fetch("/api/raw?u=" + encodeURIComponent(url)); + payload = await res.json(); + } catch { + payload = { error: "Couldn't load file." }; + } + codeCache.set(url, payload); + } + if (token !== codeReqId || card.hidden) return; // superseded or dismissed + renderCodeHeader(url, payload); + if (payload.error) { + codeBody.replaceChildren(el("div", { class: "code-error", text: payload.error })); + } else { + renderCodeBody(payload.text, payload.truncated); + } + positionCodeCard(node.getBoundingClientRect()); +} + +function scheduleHideCode() { + clearTimeout(codeHideTimer); + codeHideTimer = setTimeout(hideCodeCard, 180); +} + +function hideCodeCard() { + codeReqId += 1; // invalidate any in-flight fill + if (!codeCard) return; + codeCard.classList.remove("visible"); + codeCard.hidden = true; +} + +function bindCodeHover(node, url) { + node.addEventListener("mouseenter", () => { + clearTimeout(codeHideTimer); + clearTimeout(codeShowTimer); + codeShowTimer = setTimeout(() => showCodeCard(url, node), 200); + }); + node.addEventListener("mouseleave", () => { + clearTimeout(codeShowTimer); + scheduleHideCode(); + }); +} + /* ---------------- Previews ---------------- */ function labeledCard(name, color, card) { @@ -294,34 +574,227 @@ function renderPreviews(data) { /* ---------------- Raw ---------------- */ -function valueCell(value) { +/* ---------------- Rich value formatting ---------------- + Emphasize well-known structured bits (colors, dimensions, booleans, locales, + types, handles, dates, links) so the raw table reads at a glance. */ + +const HEX_COLOR_RE = /^#(?:[0-9a-f]{3,4}|[0-9a-f]{6}|[0-9a-f]{8})$/i; +const FN_COLOR_RE = /^(?:rgb|rgba|hsl|hsla)\([^)]*\)$/i; +const MIME_RE = /^[a-z]+\/[a-z0-9.+-]+$/i; +const LOCALE_RE = /^[a-z]{2,3}(?:[_-][A-Za-z]{2,4})?$/; +const ISO_DT_RE = + /^\d{4}-\d{2}-\d{2}(?:[T ]\d{2}:\d{2}(?::\d{2})?(?:\.\d+)?(?:Z|[+-]\d{2}:?\d{2})?)?$/; + +function vtext(children, extraCls) { + return el("span", { class: "v-text" + (extraCls ? " " + extraCls : "") }, children); +} + +/** Resolve "owner/repo" from a github.com URL, or null. */ +function nwoFromUrl(u) { + if (!u) return null; + try { + const url = new URL(/^https?:\/\//i.test(u) ? u : "https://" + u); + if (!/(^|\.)github\.com$/i.test(url.hostname)) return null; + const parts = url.pathname.split("/").filter(Boolean); + const reserved = ["orgs", "sponsors", "features", "about", "marketplace", "topics", "collections"]; + if (parts.length >= 2 && !reserved.includes(parts[0].toLowerCase())) { + return `${parts[0]}/${parts[1]}`.replace(/\.git$/i, ""); + } + } catch { + /* not a URL */ + } + return null; +} + +/** Best-effort GitHub repo (owner/repo) for the currently loaded page. */ +function githubRepoNwo() { + const d = lastData; + if (!d) return null; + const groups = d.groups || {}; + const metas = [...(groups.other || []), ...(groups.openGraph || []), ...(groups.twitter || [])]; + const nwoMeta = metas.find( + (m) => /repository[_-]nwo|github:repo/i.test(m.key) && /^[\w.-]+\/[\w.-]+$/.test(m.value), + ); + if (nwoMeta) return nwoMeta.value; + for (const u of [d.resolved && d.resolved.url, d.requestedUrl, d.resolved && d.resolved.hostname]) { + const nwo = nwoFromUrl(u); + if (nwo) return nwo; + } + return null; +} + +function formatValue(key, value) { + const raw = String(value); + const v = raw.trim(); + const lk = String(key).toLowerCase(); + + // Links + if (/^https?:\/\//i.test(v) || /^\/\//.test(v)) { + return vtext([ + el("a", { + href: v.startsWith("//") ? "https:" + v : v, + target: "_blank", + rel: "noreferrer", + text: v, + }), + ]); + } + + // Colors (hex / rgb / hsl, or color-named keys CSS can parse) + const colorish = + HEX_COLOR_RE.test(v) || + FN_COLOR_RE.test(v) || + (/color/.test(lk) && typeof CSS !== "undefined" && CSS.supports && CSS.supports("color", v)); + if (colorish) { + return vtext( + [ + el("span", { class: "color-swatch", style: `background:${v}` }), + el("code", { class: "vt-mono", text: v }), + ], + "vt-color", + ); + } + + // Numbers (dimensions, counts) + if (/^-?\d+(?:\.\d+)?$/.test(v)) { + const parts = [el("span", { class: "vt-num", text: v })]; + if (/(width|height)/.test(lk)) parts.push(el("span", { class: "vt-unit", text: "px" })); + return vtext(parts); + } + + // Booleans + if (/^(true|false|yes|no)$/i.test(v)) { + const on = /^(true|yes)$/i.test(v); + return vtext([el("span", { class: `vt-bool ${on ? "on" : "off"}`, text: v })]); + } + + // Twitter/X handles -> link to the profile on x.com + const isHandleKey = /twitter/.test(lk) && /(?:^|[:._-])(site|creator)$/i.test(lk); + if (/^@[A-Za-z0-9_]{1,30}$/.test(v) || (isHandleKey && /^@?[A-Za-z0-9_]{1,30}$/.test(v))) { + const handle = v.replace(/^@/, ""); + return vtext([ + el("a", { + class: "vt-handle", + href: "https://x.com/" + handle, + target: "_blank", + rel: "noreferrer", + text: "@" + handle, + }), + ]); + } + + // Git commit SHAs -> link to the GitHub commit when a repo is resolvable. + if (/^[0-9a-f]{7,40}$/i.test(v)) { + const shaKey = /(commit|sha|revision|changeset|\bgit\b|\brev\b)/i.test(lk); + const nwo = shaKey || v.length >= 20 ? githubRepoNwo() : null; + if (shaKey || nwo) { + const short = v.length > 12 ? v.slice(0, 10) : v; + if (nwo) { + return vtext([ + el("a", { + class: "vt-commit", + href: `https://github.com/${nwo}/commit/${v}`, + target: "_blank", + rel: "noreferrer", + title: `${nwo}@${v}`, + text: short, + }), + ]); + } + return vtext([el("code", { class: "vt-mono", title: v, text: short })]); + } + } + + // ISO date / time -> friendly, with the raw value preserved for copy/hover + if (ISO_DT_RE.test(v)) { + const d = new Date(v); + if (!Number.isNaN(d.getTime())) { + const nice = d.toLocaleString(undefined, { + year: "numeric", + month: "short", + day: "numeric", + hour: "numeric", + minute: "2-digit", + }); + return vtext([el("time", { class: "vt-time", datetime: v, title: v, text: nice })]); + } + } + + // MIME types + if (MIME_RE.test(v)) { + return vtext([el("span", { class: "vt-token mono", text: v })]); + } + + // Enumerated tokens on known structured keys + if (/(?:^|[:._-])(type|card|determiner)$/i.test(lk) && /^[a-z][\w.-]{0,40}$/i.test(v)) { + return vtext([el("span", { class: "vt-token accent", text: v })]); + } + if (/(?:^|[:._-])locale(?::alternate)?$/i.test(lk) && LOCALE_RE.test(v)) { + return vtext([el("span", { class: "vt-token mono", text: v })]); + } + if (/(?:^|[:._-])(charset|encoding|robots|googlebot|referrer|rating)$/i.test(lk)) { + return vtext([el("span", { class: "vt-token", text: v })]); + } + + // Default + return vtext([raw]); +} + +function valueCell(key, value) { const td = el("td", { class: "v" }); - const text = el("span", { class: "v-text" }); - if (/^https?:\/\//i.test(value)) { - text.appendChild(el("a", { href: value, target: "_blank", rel: "noreferrer", text: value })); - } else { - text.textContent = value; + const content = formatValue(key, value); + const row = el("div", { class: "v-row" }, [content, copyButton(value, "Copy value")]); + if (looksLikeImage(key, value) && /^(https?:)?\/\//i.test(value)) { + row.classList.add("has-img"); + bindImageHover(content, value); + } else if (looksLikeCode(value)) { + row.classList.add("has-code"); + bindCodeHover(content, value); } - td.appendChild(el("div", { class: "v-row" }, [text, copyButton(value, "Copy value")])); + td.appendChild(row); return td; } +/** Heuristic: does this key/value name an image asset we can preview? */ +function looksLikeImage(key, value) { + if (/(image|icon|favicon|logo|thumbnail|banner|photo|avatar|apple-touch)/i.test(key || "")) { + return true; + } + return /\.(png|jpe?g|gif|webp|svg|avif|bmp|ico|tiff?)(\?|#|$)/i.test(value || ""); +} + function kvTable(rows) { const table = el("table", { class: "kv" }); for (const { key, value } of rows) { table.appendChild( - el("tr", null, [el("td", { class: "k", text: key }), valueCell(value)]), + el("tr", null, [ + el("td", { class: "k", text: key }), + valueCell(key, value), + ]), ); } return table; } +function rawChevron() { + return el("span", { + class: "raw-chevron", + html: '', + }); +} + function rawGroup(title, rows) { if (!rows || rows.length === 0) return null; - return el("div", { class: "raw-group" }, [ - el("h3", null, [title, el("span", { class: "count muted", text: `(${rows.length})` })]), - kvTable(rows), - ]); + const details = el("details", { class: "raw-group", open: "" }); + details.appendChild( + el("summary", null, [ + rawChevron(), + el("span", { class: "raw-title", text: title }), + el("span", { class: "count", text: String(rows.length) }), + ]), + ); + details.appendChild(kvTable(rows)); + return details; } function renderRaw(data) { @@ -383,13 +856,6 @@ function renderDiagnostics(data) { /* ---------------- Footer (page info) ---------------- */ -function fact(label, valueNode) { - return el("div", { class: "fact" }, [ - el("div", { class: "fact-label", text: label }), - el("div", { class: "fact-value" }, [valueNode]), - ]); -} - function statusPill(httpStatus) { const cls = !httpStatus ? "warn" : httpStatus < 300 ? "ok" : httpStatus < 400 ? "warn" : "req"; return el("span", { class: `pill ${cls}`, text: httpStatus ? String(httpStatus) : "—" }); @@ -402,28 +868,54 @@ function renderFooter(data) { data.tagCount } tags`; - const urlNode = el("a", { - href: data.requestedUrl, - target: "_blank", - rel: "noreferrer", - text: data.requestedUrl, - }); - - const diagNode = el("span", null, [ + // Compact stat strip: HTTP status, tag count, and diagnostics roll-up. + const stats = el("div", { class: "footer-stats" }, [ + statusPill(data.httpStatus), + el("span", { class: "stat-chip" }, [ + el("span", { class: "stat-num", text: String(data.tagCount) }), + el("span", { class: "stat-lbl", text: data.tagCount === 1 ? "tag" : "tags" }), + ]), + el("span", { class: "stat-sep" }), el("span", { class: "pill ok", text: `${counts.ok} ok` }), - " ", counts.warn ? el("span", { class: "pill warn", text: `${counts.warn} warn` }) : null, - counts.warn ? " " : null, counts.req ? el("span", { class: "pill req", text: `${counts.req} missing` }) : null, ]); - const body = $("#footer-body"); - body.replaceChildren( - fact("Final URL", el("span", { class: "v-row" }, [urlNode, copyButton(data.requestedUrl, "Copy URL")])), - fact("HTTP status", statusPill(data.httpStatus)), - fact("Meta tags", el("span", { text: String(data.tagCount) })), - fact("Diagnostics", diagNode), + const rows = [stats]; + + rows.push( + el("div", { class: "footer-line" }, [ + el("span", { class: "footer-line-label", text: "URL" }), + el("a", { + class: "footer-line-val", + href: data.requestedUrl, + target: "_blank", + rel: "noreferrer", + text: data.requestedUrl, + }), + copyButton(data.requestedUrl, "Copy URL"), + ]), ); + + // Only surface a canonical line when it actually differs from the request. + const canon = data.resolved && data.resolved.url; + if (canon && canon !== data.requestedUrl) { + rows.push( + el("div", { class: "footer-line" }, [ + el("span", { class: "footer-line-label", text: "Canonical" }), + el("a", { + class: "footer-line-val", + href: canon, + target: "_blank", + rel: "noreferrer", + text: canon, + }), + copyButton(canon, "Copy canonical URL"), + ]), + ); + } + + $("#footer-body").replaceChildren(...rows); } function setFooterOpen(open) { @@ -455,6 +947,8 @@ function setStatus(kind, message) { async function load(rawUrl) { const url = withScheme(rawUrl); if (!url) return; + hideImgTip(); + hideCodeCard(); input.value = url; document.body.classList.add("has-data", "is-busy"); setStatus("loading", `Fetching ${url} …`); @@ -530,6 +1024,17 @@ $("#footer-toggle").addEventListener("click", () => { setFooterOpen($("#footer").dataset.collapsed === "true"); }); +// Dismiss floating hover surfaces (image tip + code card) when context shifts. +function dismissHovers() { + hideImgTip(); + hideCodeCard(); +} +$(".content").addEventListener("scroll", dismissHovers, { passive: true }); +window.addEventListener("blur", dismissHovers); +document.addEventListener("keydown", (e) => { + if (e.key === "Escape") dismissHovers(); +}); + $("#copy-json").addEventListener("click", async () => { if (!lastData) return; const payload = JSON.stringify( @@ -584,3 +1089,45 @@ if (initial) { input.value = initial; load(initial); } + +/* ---------------- Theme awareness for preview cards ---------------- + The brand cards mimic each platform, but should still read like that + platform's DARK UI when the app is in dark mode (instead of glaring white). + We can't trust data-color-mode (it may be "auto"), so derive the effective + mode from the actual computed background luminance and expose it as + body[data-mode]; CSS supplies brand dark variants under that selector. */ +function channelLuminance(rgb) { + const m = String(rgb).match(/[\d.]+/g); + if (!m || m.length < 3) return 1; + const [r, g, b] = m.slice(0, 3).map(Number); + return (0.2126 * r + 0.7152 * g + 0.0722 * b) / 255; +} + +function applyPreviewMode() { + try { + const bg = getComputedStyle(document.body).backgroundColor; + document.body.dataset.mode = channelLuminance(bg) < 0.5 ? "dark" : "light"; + } catch { + /* getComputedStyle unavailable */ + } +} + +applyPreviewMode(); +try { + const themeObserver = new MutationObserver(applyPreviewMode); + const opts = { + attributes: true, + attributeFilter: [ + "data-color-mode", + "data-visual-mode", + "data-dark-theme", + "data-light-theme", + "class", + "style", + ], + }; + themeObserver.observe(document.documentElement, opts); + themeObserver.observe(document.body, opts); +} catch { + /* MutationObserver unavailable */ +} diff --git a/.github/extensions/og-preview/ui/styles.css b/.github/extensions/og-preview/ui/styles.css index ab7fbaae8..b2d844414 100644 --- a/.github/extensions/og-preview/ui/styles.css +++ b/.github/extensions/og-preview/ui/styles.css @@ -40,6 +40,14 @@ box-sizing: border-box; } +/* The hidden attribute must always win. Some elements below set an explicit + display (flex/grid), which would otherwise override [hidden]'s UA + display:none and leave toggled regions (status banner, footer body) stuck + visible. */ +[hidden] { + display: none !important; +} + html, body { margin: 0; @@ -253,8 +261,7 @@ code { .examples-label { font-size: 12px; - text-transform: uppercase; - letter-spacing: 0.04em; + letter-spacing: 0.01em; } .chip { @@ -384,10 +391,8 @@ body.is-busy .empty { display: flex; align-items: center; gap: 8px; - font-size: 12px; + font-size: 12.5px; font-weight: var(--font-weight-semibold, 600); - text-transform: uppercase; - letter-spacing: 0.04em; color: var(--text-color-muted, #59636e); margin-bottom: 8px; } @@ -594,6 +599,69 @@ body.is-busy .empty { color: var(--text-color-muted, #59636e); } +/* Dark-mode brand variants. The cards still mimic each product, but switch to + that platform's DARK palette when the app is dark so they don't glare. Gated + on body[data-mode="dark"], which app.js derives from background luminance. + Discord and X(large) already ship dark, so only the lighter ones need help. */ +body[data-mode="dark"] .card-img { + background-color: #1a1d21; +} + +body[data-mode="dark"] .fb { + background: #242526; + color: #e4e6eb; + border-color: #3a3b3c; +} +body[data-mode="dark"] .fb .meta { + background: #3a3b3c; + border-top-color: #4e4f50; +} +body[data-mode="dark"] .fb .domain, +body[data-mode="dark"] .fb .desc { + color: #b0b3b8; +} + +body[data-mode="dark"] .x { + background: #000; + color: #e7e9ea; + border-color: #2f3336; +} +body[data-mode="dark"] .x .title { + color: #e7e9ea; +} +body[data-mode="dark"] .x .desc, +body[data-mode="dark"] .x .domain { + color: #71767b; +} +body[data-mode="dark"] .x.small .card-img { + border-right-color: #2f3336; +} + +body[data-mode="dark"] .li { + background: #1b1f23; + color: rgba(255, 255, 255, 0.9); + border-color: #38434f; + box-shadow: 0 0 0 1px rgba(255, 255, 255, 0.04); +} +body[data-mode="dark"] .li .meta { + background: #283139; +} +body[data-mode="dark"] .li .domain { + color: rgba(255, 255, 255, 0.6); +} + +body[data-mode="dark"] .slack { + background: #1a1d21; + color: #d1d2d3; + border-left-color: #35373b; +} +body[data-mode="dark"] .slack .title { + color: #1d9bd1; +} +body[data-mode="dark"] .slack .desc { + color: #d1d2d3; +} + /* ---------- Raw ---------- */ .raw-actions { display: flex; @@ -604,29 +672,69 @@ body.is-busy .empty { } .raw-group { - margin-bottom: 22px; + margin-bottom: 12px; border: 1px solid var(--border-soft); border-radius: var(--radius-lg); overflow: hidden; background: var(--surface-raised); } -.raw-group h3 { - font-size: 12px; - text-transform: uppercase; - letter-spacing: 0.04em; - color: var(--text-color-muted, #59636e); +.raw-group > summary { + list-style: none; + cursor: pointer; + user-select: none; + font-size: 12.5px; + color: var(--text-color-default, #1f2328); margin: 0; padding: 9px 12px; background: var(--surface-inset); - border-bottom: 1px solid var(--border-soft); display: flex; align-items: center; gap: 8px; + transition: background-color 0.12s ease; } -.raw-group h3 .count { +.raw-group > summary::-webkit-details-marker { + display: none; +} + +.raw-group > summary::marker { + content: ""; +} + +.raw-group > summary:hover { + background: var(--surface-muted); +} + +.raw-group > summary:focus-visible { + outline: 2px solid var(--accent); + outline-offset: -2px; +} + +.raw-group[open] > summary { + border-bottom: 1px solid var(--border-soft); +} + +.raw-chevron { + display: inline-flex; + color: var(--text-color-muted, #59636e); + transition: transform 0.15s ease; + flex: 0 0 auto; +} + +.raw-group[open] > summary .raw-chevron { + transform: rotate(90deg); +} + +.raw-title { + font-weight: var(--font-weight-semibold, 600); +} + +.raw-group .count { + margin-left: auto; font-weight: 400; + font-variant-numeric: tabular-nums; + color: var(--text-color-muted, #59636e); } table.kv { @@ -716,6 +824,294 @@ table.kv td.v a:hover { color: var(--true-color-green, #1a7f37); } +/* ---------- Image hover preview tooltip ---------- */ +.has-img .v-text { + cursor: zoom-in; +} + +.img-tip { + position: fixed; + left: 0; + top: 0; + z-index: 60; + pointer-events: none; + padding: 6px; + max-width: 272px; + background: var(--background-color-default, #ffffff); + border: 1px solid var(--border-color-default, #d1d9e0); + border-radius: var(--radius-lg); + box-shadow: var(--shadow-pop); + opacity: 0; + transform: scale(0.96); + transition: opacity 0.1s ease, transform 0.1s ease; +} + +.img-tip.visible { + opacity: 1; + transform: none; +} + +.img-tip img { + display: block; + max-width: 260px; + max-height: 190px; + border-radius: 5px; + object-fit: contain; + background: var(--surface-inset); +} + +.img-tip-meta { + margin-top: 5px; + font-size: 11px; + text-align: center; + color: var(--text-color-muted, #59636e); + font-family: var(--font-mono, monospace); +} + +/* ---------- Rich value formatting (raw metadata) ---------- */ +.vt-mono, +.vt-num { + font-family: var(--font-mono, monospace); +} +.vt-num { + font-variant-numeric: tabular-nums; + font-weight: var(--font-weight-semibold, 600); +} +.vt-unit { + color: var(--text-color-muted, #59636e); + margin-left: 2px; + font-size: 11px; +} + +.vt-color { + display: inline-flex; + align-items: center; + gap: 6px; +} +.color-swatch { + width: 13px; + height: 13px; + border-radius: 3px; + flex: 0 0 auto; + border: 1px solid var(--border-soft); + box-shadow: inset 0 0 0 1px rgba(255, 255, 255, 0.35); +} + +.vt-bool { + display: inline-flex; + align-items: center; + gap: 5px; + padding: 0 8px; + border-radius: 999px; + font-size: 12px; + font-weight: var(--font-weight-semibold, 600); + border: 1px solid var(--border-soft); +} +.vt-bool::before { + content: ""; + width: 6px; + height: 6px; + border-radius: 50%; + background: currentColor; +} +.vt-bool.on { + color: var(--true-color-green, #1a7f37); + border-color: color-mix(in srgb, var(--true-color-green, #1a7f37) 40%, transparent); +} +.vt-bool.off { + color: var(--text-color-muted, #59636e); +} + +.vt-handle, +.vt-commit { + font-weight: var(--font-weight-semibold, 600); +} +.vt-commit { + display: inline-flex; + align-items: center; + gap: 4px; + font-family: var(--font-mono, monospace); +} +.vt-commit::before { + content: ""; + width: 13px; + height: 13px; + flex: 0 0 auto; + background: currentColor; + -webkit-mask: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16'%3E%3Cpath d='M11.93 8.5a4.002 4.002 0 0 1-7.86 0H.75a.75.75 0 0 1 0-1.5h3.32a4.002 4.002 0 0 1 7.86 0h3.32a.75.75 0 0 1 0 1.5Zm-1.43-.75a2.5 2.5 0 1 0-5 0 2.5 2.5 0 0 0 5 0Z'/%3E%3C/svg%3E") + center / contain no-repeat; + mask: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16'%3E%3Cpath d='M11.93 8.5a4.002 4.002 0 0 1-7.86 0H.75a.75.75 0 0 1 0-1.5h3.32a4.002 4.002 0 0 1 7.86 0h3.32a.75.75 0 0 1 0 1.5Zm-1.43-.75a2.5 2.5 0 1 0-5 0 2.5 2.5 0 0 0 5 0Z'/%3E%3C/svg%3E") + center / contain no-repeat; +} + +.vt-time { + border-bottom: 1px dotted var(--border-soft); + cursor: help; +} + +.vt-token { + display: inline-block; + padding: 0 7px; + border-radius: var(--radius); + font-size: 12px; + background: var(--surface-inset); + border: 1px solid var(--border-soft); +} +.vt-token.mono { + font-family: var(--font-mono, monospace); +} +.vt-token.accent { + color: var(--accent); + background: var(--accent-soft); + border-color: color-mix(in srgb, var(--accent) 30%, transparent); +} + +/* ---------- Code / file hover card ---------- */ +.has-code .v-text { + cursor: help; +} + +.code-card { + position: fixed; + left: 0; + top: 0; + z-index: 70; + width: min(560px, calc(100vw - 24px)); + max-height: min(420px, calc(100vh - 24px)); + display: flex; + flex-direction: column; + overflow: hidden; + background: var(--surface-raised); + border: 1px solid var(--border-color-default, #d1d9e0); + border-radius: var(--radius-lg); + box-shadow: var(--shadow-pop); + opacity: 0; + transform: translateY(4px) scale(0.99); + transition: opacity 0.12s ease, transform 0.12s ease; +} +.code-card.visible { + opacity: 1; + transform: none; +} + +.code-card-head { + display: flex; + align-items: center; + gap: 8px; + padding: 7px 10px; + border-bottom: 1px solid var(--border-soft); + background: var(--surface-inset); +} +.code-lang { + flex: 0 0 auto; + padding: 0 6px; + border-radius: 999px; + font-size: 11px; + font-weight: var(--font-weight-semibold, 600); + color: var(--accent); + background: var(--accent-soft); + border: 1px solid color-mix(in srgb, var(--accent) 30%, transparent); +} +.code-name { + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + font-family: var(--font-mono, monospace); + font-size: 12px; + color: var(--text-color-default, #1f2328); +} +.code-meta { + flex: 0 0 auto; + margin-left: auto; + font-size: 11px; +} +.code-open, +.code-copy { + flex: 0 0 auto; + width: 24px; + height: 24px; + display: inline-flex; + align-items: center; + justify-content: center; + border: 1px solid transparent; + border-radius: var(--radius); + background: transparent; + color: var(--text-color-muted, #59636e); + cursor: pointer; + text-decoration: none; +} +.code-open:hover, +.code-copy:hover { + background: var(--surface-muted); + color: var(--text-color-default, #1f2328); +} +.code-copy:disabled { + opacity: 0.5; + cursor: default; +} +.code-copy.copied { + color: var(--true-color-green, #1a7f37); +} + +.code-card-body { + flex: 1; + min-height: 0; + display: flex; + flex-direction: column; +} +.code-scroll { + flex: 1; + min-height: 0; + overflow: auto; + padding: 6px 0; + font-family: var(--font-mono, monospace); + font-size: 12px; + line-height: 1.55; +} +.code-lines { + min-width: min-content; +} +.cl { + display: flex; +} +.cl:hover { + background: var(--surface-inset); +} +.cl-n { + flex: 0 0 auto; + min-width: 44px; + padding: 0 10px; + text-align: right; + user-select: none; + position: sticky; + left: 0; + color: var(--text-color-muted, #59636e); + background: var(--surface-raised); + border-right: 1px solid var(--border-soft); +} +.cl:hover .cl-n { + background: var(--surface-inset); +} +.cl-t { + white-space: pre; + padding: 0 12px; + color: var(--text-color-default, #1f2328); +} +.code-loading, +.code-error, +.code-more { + padding: 14px; + font-size: 12px; + color: var(--text-color-muted, #59636e); +} +.code-error { + color: var(--true-color-red, #cf222e); +} +.code-more { + border-top: 1px solid var(--border-soft); +} + /* ---------- Diagnostics ---------- */ #diagnostics { border: 1px solid var(--border-soft); @@ -764,8 +1160,8 @@ table.kv td.v a:hover { .diag-level { font-size: 11px; - text-transform: uppercase; - letter-spacing: 0.03em; + text-transform: capitalize; + letter-spacing: 0.01em; color: var(--text-color-muted, #59636e); margin-left: 6px; } @@ -822,44 +1218,70 @@ table.kv td.v a:hover { .footer-hint { font-size: 11px; - text-transform: uppercase; - letter-spacing: 0.04em; + letter-spacing: 0.01em; flex: 0 0 auto; } .footer-body { padding: 4px 16px 14px; - display: grid; - grid-template-columns: repeat(auto-fill, minmax(200px, 1fr)); - gap: 10px 18px; + display: flex; + flex-direction: column; + gap: 10px; } -.fact { - min-width: 0; +.footer-stats { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 6px 8px; } -.fact-label { +.stat-chip { + display: inline-flex; + align-items: baseline; + gap: 4px; + padding: 1px 8px; + border-radius: 999px; + border: 1px solid var(--border-soft); + background: var(--surface-inset); +} +.stat-num { + font-weight: var(--font-weight-semibold, 600); + font-variant-numeric: tabular-nums; +} +.stat-lbl { font-size: 11px; - text-transform: uppercase; - letter-spacing: 0.04em; color: var(--text-color-muted, #59636e); - margin-bottom: 2px; +} +.stat-sep { + width: 1px; + align-self: stretch; + margin: 1px 2px; + background: var(--border-soft); } -.fact-value { - font-size: 13px; - word-break: break-word; +.footer-line { display: flex; align-items: center; - gap: 6px; + gap: 8px; + min-width: 0; + font-size: 12.5px; } - -.fact-value a { +.footer-line-label { + flex: 0 0 auto; + min-width: 64px; + font-size: 11px; + color: var(--text-color-muted, #59636e); +} +.footer-line-val { + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; color: var(--accent); text-decoration: none; } - -.fact-value a:hover { +.footer-line-val:hover { text-decoration: underline; } From 3e8856c114330cd9020ff7d1d6ade7a9cac925a3 Mon Sep 17 00:00:00 2001 From: David Pine <7679720+IEvangelist@users.noreply.github.com> Date: Tue, 30 Jun 2026 08:26:43 -0500 Subject: [PATCH 05/21] Refine og-preview: srcdoc browse tab, masonry previews, unified chevrons, faithful X card MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Make Browse the default first tab; it now drives the previews. In-frame navigation binds the landed route to the top-level URL (bidirectional). - Render the browse frame via srcdoc (inline proxied HTML) so it loads inside the canvas host without "refused to connect"; bridge no longer self-navigates. - Lay previews out in a multi-column masonry grid to remove wasted vertical space. - Unify all disclosure chevrons (previews, raw metadata, diagnostics, footer) to one stroked chevron with a smooth transition; move the preview chevron to the right of its header. - Polish collapsible sections: rounded header bounding box, hover state, and a grid-rows height animation. - Fix the X/Twitter card to render summary_large_image faithfully — image with a bottom-left domain pill and no overlapping/floating text; small summary card used as the no-image fallback. - Add Bluesky and Microsoft Teams previews with brand icons; prefix the canvas title with "OG Viewer - {url}". Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .github/extensions/og-preview/extension.mjs | 119 +++- .github/extensions/og-preview/ui/app.js | 487 ++++++++++++++-- .github/extensions/og-preview/ui/index.html | 66 ++- .github/extensions/og-preview/ui/styles.css | 579 ++++++++++++++++++-- 4 files changed, 1153 insertions(+), 98 deletions(-) diff --git a/.github/extensions/og-preview/extension.mjs b/.github/extensions/og-preview/extension.mjs index e945316a1..fa901a914 100644 --- a/.github/extensions/og-preview/extension.mjs +++ b/.github/extensions/og-preview/extension.mjs @@ -77,15 +77,84 @@ function sendJson(res, status, obj) { res.end(JSON.stringify(obj)); } +function escapeHtmlAttr(s) { + return String(s) + .replace(/&/g, "&") + .replace(/"/g, """) + .replace(//g, ">"); +} + +// Small in-frame bridge injected into proxied pages. Runs in a sandboxed +// (opaque-origin) iframe, so it can't touch the host canvas document but can +// postMessage navigation events to the parent. It also keeps link clicks and +// SPA history changes flowing back through /api/proxy so the browse frame stays +// embeddable and the parent can mirror the live route into the preview. +function browseBridgeScript(finalUrl, proxyBase) { + const real = JSON.stringify(finalUrl).replace(/(function(){ +var REAL=${real}; +var PROXY=${base}; +function post(u){try{parent.postMessage({source:"og-browse",type:"nav",url:u},"*");}catch(e){}} +function px(a){return PROXY+"?u="+encodeURIComponent(a);} +document.addEventListener("click",function(e){ + if(e.defaultPrevented||e.button!==0||e.metaKey||e.ctrlKey||e.shiftKey||e.altKey)return; + var a=e.target&&e.target.closest?e.target.closest("a[href]"):null; + if(!a)return; + var href=a.getAttribute("href"); + if(!href||href.charAt(0)==="#")return; + if(/^(mailto:|tel:|javascript:|data:)/i.test(href))return; + if(a.target&&a.target!==""&&a.target!=="_self")return; + var abs;try{abs=new URL(a.href,REAL).toString();}catch(_){return;} + if(!/^https?:/i.test(abs))return; + e.preventDefault();post(abs); +},true); +function wrap(n){var o=history[n];if(typeof o!=="function")return;history[n]=function(){var r=o.apply(this,arguments);try{var u=arguments[2];if(u!=null)post(new URL(String(u),REAL).toString());}catch(_){}return r;};} +wrap("pushState");wrap("replaceState"); +if(document.readyState==="loading")document.addEventListener("DOMContentLoaded",function(){post(REAL);});else post(REAL); +})();<\/script>`; +} + +// Rewrite a fetched HTML document so it renders inside the browse frame: drop +// the page's own and any CSP (so our inline bridge isn't blocked), +// then inject a pointing at the real origin plus the bridge script. +function injectBrowseBridge(html, finalUrl, proxyBase) { + let out = html + .replace(/]*>/gi, "") + .replace(/]+http-equiv\s*=\s*["']?content-security-policy["']?[^>]*>/gi, ""); + const inject = `` + browseBridgeScript(finalUrl, proxyBase); + if (/]*>/i.test(out)) { + out = out.replace(/]*>/i, (m) => m + inject); + } else if (/]*>/i.test(out)) { + out = out.replace(/]*>/i, (m) => m + "" + inject + ""); + } else { + out = "" + inject + "" + out; + } + return out; +} + +function browseErrorPage(rawUrl, message) { + return ( + `
` + + `

Couldn't load this page

${escapeHtmlAttr(message)}

` + + `

${escapeHtmlAttr(rawUrl)}

` + ); +} + /** Human-readable panel title for the host chrome (scheme stripped for brevity). */ function displayTitle(url) { if (!url) return "OpenGraph Preview"; try { const u = new URL(url); const path = u.pathname === "/" ? "" : u.pathname.replace(/\/+$/, ""); - return `OG · ${u.host}${path}${u.search}`; + return `OG Viewer - ${u.host}${path}${u.search}`; } catch { - return `OG · ${url}`; + return `OG Viewer - ${url}`; } } @@ -156,13 +225,18 @@ async function handleRequest(entry, req, res) { if (path === "/api/fetch") { const u = reqUrl.searchParams.get("u"); if (!u) return sendJson(res, 400, { error: "Missing 'u' query parameter." }); + // `silent` loads (e.g. driven by in-canvas browsing) update the metadata + // but DON'T re-open the canvas to refresh the host title — re-opening + // focuses the panel and reloads the whole iframe, which would yank the + // user out of the Browse tab on every in-page navigation. + const silent = reqUrl.searchParams.get("silent") === "1"; try { const data = await loadMetadata(u); entry.currentUrl = data.requestedUrl; sendJson(res, 200, data); // Refresh the host panel title to the resolved URL (fire-and-forget; // guarded against loops by syncTitle). - syncTitle(entry, data.requestedUrl).catch(() => {}); + if (!silent) syncTitle(entry, data.requestedUrl).catch(() => {}); return; } catch (err) { return sendJson(res, 200, { error: err.message }); @@ -224,6 +298,41 @@ async function handleRequest(entry, req, res) { } } + if (path === "/api/proxy") { + const u = reqUrl.searchParams.get("u"); + if (!u) { + res.statusCode = 400; + return res.end("Missing 'u'"); + } + try { + const r = await fetchUrl(normalizeUrl(u), { + accept: "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8", + timeoutMs: 15000, + }); + const ct = r.contentType || ""; + const isHtml = !ct || /text\/html|application\/xhtml\+xml|\/xml|text\/plain/i.test(ct); + res.setHeader("Cache-Control", "no-store"); + if (!isHtml) { + // Serve non-HTML targets (images, PDFs, …) verbatim so links to + // them still render inside the browse frame. + res.statusCode = r.status >= 400 ? r.status : 200; + res.setHeader("Content-Type", ct || "application/octet-stream"); + return res.end(r.body); + } + // Build our own response and intentionally DO NOT forward + // X-Frame-Options / CSP, so the page is embeddable in the canvas. + res.statusCode = 200; + res.setHeader("Content-Type", "text/html; charset=utf-8"); + const appOrigin = "http://" + (req.headers.host || "127.0.0.1"); + return res.end(injectBrowseBridge(r.body.toString("utf8"), r.url, appOrigin + "/api/proxy")); + } catch (err) { + res.statusCode = 200; + res.setHeader("Content-Type", "text/html; charset=utf-8"); + res.setHeader("Cache-Control", "no-store"); + return res.end(browseErrorPage(u, err && err.message ? err.message : String(err))); + } + } + res.statusCode = 404; res.end("Not found"); } @@ -259,9 +368,9 @@ function instanceUrl(entry) { const ogCanvas = createCanvas({ id: "og-preview", - displayName: "OpenGraph Preview", + displayName: "OG Viewer", description: - "Preview how a URL unfurls on Facebook, X, LinkedIn, Slack, and Discord, with a raw OpenGraph metadata and diagnostics view. Supports localhost.", + "Preview how a URL unfurls on Facebook, X, Bluesky, LinkedIn, Slack, Teams, and Discord, with a raw OpenGraph metadata and diagnostics view. Supports localhost.", inputSchema: { type: "object", properties: { diff --git a/.github/extensions/og-preview/ui/app.js b/.github/extensions/og-preview/ui/app.js index acff6b4b4..fb56cadce 100644 --- a/.github/extensions/og-preview/ui/app.js +++ b/.github/extensions/og-preview/ui/app.js @@ -32,6 +32,69 @@ function prettyDomain(host) { return String(host || "").replace(/^www\./i, ""); } +/* ---------------- Iconography (Octicons, MIT-licensed) ---------------- */ + +const OCTICONS = { + image: + '', + code: + '', + checklist: + '', + globe: + '', + mention: + '', + tag: + '', + link: + '', + "link-external": + '', +}; + +function octicon(name, size, cls) { + const span = el("span", { class: "octicon" + (cls ? " " + cls : ""), "aria-hidden": "true" }); + span.innerHTML = `${ + OCTICONS[name] || "" + }`; + return span; +} + +// Platform brand marks (simple-icons, CC0). 24×24 viewBox, single path. +const BRAND_ICONS = { + facebook: + "M9.101 23.691v-7.98H6.627v-3.667h2.474v-1.58c0-4.085 1.848-5.978 5.858-5.978.401 0 .955.042 1.468.103a8.68 8.68 0 0 1 1.141.195v3.325a8.623 8.623 0 0 0-.653-.036 26.805 26.805 0 0 0-.733-.009c-.707 0-1.259.096-1.675.309a1.686 1.686 0 0 0-.679.622c-.258.42-.374.995-.374 1.752v1.297h3.919l-.386 2.103-.287 1.564h-3.246v8.245C19.396 23.238 24 18.179 24 12.044c0-6.627-5.373-12-12-12s-12 5.373-12 12c0 5.628 3.874 10.35 9.101 11.647Z", + x: + "M14.234 10.162 22.977 0h-2.072l-7.591 8.824L7.251 0H.258l9.168 13.343L.258 24H2.33l8.016-9.318L16.749 24h6.993zm-2.837 3.299-.929-1.329L3.076 1.56h3.182l5.965 8.532.929 1.329 7.754 11.09h-3.182z", + linkedin: + "M20.447 20.452h-3.554v-5.569c0-1.328-.027-3.037-1.852-3.037-1.853 0-2.136 1.445-2.136 2.939v5.667H9.351V9h3.414v1.561h.046c.477-.9 1.637-1.85 3.37-1.85 3.601 0 4.267 2.37 4.267 5.455v6.286zM5.337 7.433c-1.144 0-2.063-.926-2.063-2.065 0-1.138.92-2.063 2.063-2.063 1.14 0 2.064.925 2.064 2.063 0 1.139-.925 2.065-2.064 2.065zm1.782 13.019H3.555V9h3.564v11.452zM22.225 0H1.771C.792 0 0 .774 0 1.729v20.542C0 23.227.792 24 1.771 24h20.451C23.2 24 24 23.227 24 22.271V1.729C24 .774 23.2 0 22.222 0h.003z", + slack: + "M5.042 15.165a2.528 2.528 0 0 1-2.52 2.523A2.528 2.528 0 0 1 0 15.165a2.527 2.527 0 0 1 2.522-2.52h2.52v2.52zM6.313 15.165a2.527 2.527 0 0 1 2.521-2.52 2.527 2.527 0 0 1 2.521 2.52v6.313A2.528 2.528 0 0 1 8.834 24a2.528 2.528 0 0 1-2.521-2.522v-6.313zM8.834 5.042a2.528 2.528 0 0 1-2.521-2.52A2.528 2.528 0 0 1 8.834 0a2.528 2.528 0 0 1 2.521 2.522v2.52H8.834zM8.834 6.313a2.528 2.528 0 0 1 2.521 2.521 2.528 2.528 0 0 1-2.521 2.521H2.522A2.528 2.528 0 0 1 0 8.834a2.528 2.528 0 0 1 2.522-2.521h6.312zM18.956 8.834a2.528 2.528 0 0 1 2.522-2.521A2.528 2.528 0 0 1 24 8.834a2.528 2.528 0 0 1-2.522 2.521h-2.522V8.834zM17.688 8.834a2.528 2.528 0 0 1-2.523 2.521 2.527 2.527 0 0 1-2.52-2.521V2.522A2.527 2.527 0 0 1 15.165 0a2.528 2.528 0 0 1 2.523 2.522v6.312zM15.165 18.956a2.528 2.528 0 0 1 2.523 2.522A2.528 2.528 0 0 1 15.165 24a2.527 2.527 0 0 1-2.52-2.522v-2.522h2.52zM15.165 17.688a2.527 2.527 0 0 1-2.52-2.523 2.526 2.526 0 0 1 2.52-2.52h6.313A2.527 2.527 0 0 1 24 15.165a2.528 2.528 0 0 1-2.522 2.523h-6.313z", + discord: + "M20.317 4.3698a19.7913 19.7913 0 00-4.8851-1.5152.0741.0741 0 00-.0785.0371c-.211.3753-.4447.8648-.6083 1.2495-1.8447-.2762-3.68-.2762-5.4868 0-.1636-.3933-.4058-.8742-.6177-1.2495a.077.077 0 00-.0785-.037 19.7363 19.7363 0 00-4.8852 1.515.0699.0699 0 00-.0321.0277C.5334 9.0458-.319 13.5799.0992 18.0578a.0824.0824 0 00.0312.0561c2.0528 1.5076 4.0413 2.4228 5.9929 3.0294a.0777.0777 0 00.0842-.0276c.4616-.6304.8731-1.2952 1.226-1.9942a.076.076 0 00-.0416-.1057c-.6528-.2476-1.2743-.5495-1.8722-.8923a.077.077 0 01-.0076-.1277c.1258-.0943.2517-.1923.3718-.2914a.0743.0743 0 01.0776-.0105c3.9278 1.7933 8.18 1.7933 12.0614 0a.0739.0739 0 01.0785.0095c.1202.099.246.1981.3728.2924a.077.077 0 01-.0066.1276 12.2986 12.2986 0 01-1.873.8914.0766.0766 0 00-.0407.1067c.3604.698.7719 1.3628 1.225 1.9932a.076.076 0 00.0842.0286c1.961-.6067 3.9495-1.5219 6.0023-3.0294a.077.077 0 00.0313-.0552c.5004-5.177-.8382-9.6739-3.5485-13.6604a.061.061 0 00-.0312-.0286zM8.02 15.3312c-1.1825 0-2.1569-1.0857-2.1569-2.419 0-1.3332.9555-2.4189 2.157-2.4189 1.2108 0 2.1757 1.0952 2.1568 2.419 0 1.3332-.9555 2.4189-2.1569 2.4189zm7.9748 0c-1.1825 0-2.1569-1.0857-2.1569-2.419 0-1.3332.9554-2.4189 2.1569-2.4189 1.2108 0 2.1757 1.0952 2.1568 2.419 0 1.3332-.946 2.4189-2.1568 2.4189Z", + bluesky: + "M5.202 2.857C7.954 4.922 10.913 9.11 12 11.358c1.087-2.247 4.046-6.436 6.798-8.501C20.783 1.366 24 .213 24 3.883c0 .732-.42 6.156-.667 7.037-.856 3.061-3.978 3.842-6.755 3.37 4.854.826 6.089 3.562 3.422 6.299-5.065 5.196-7.28-1.304-7.847-2.97-.104-.305-.152-.448-.153-.327 0-.121-.05.022-.153.327-.568 1.666-2.782 8.166-7.847 2.97-2.667-2.737-1.432-5.473 3.422-6.3-2.777.473-5.899-.308-6.755-3.369C.42 10.04 0 4.615 0 3.883c0-3.67 3.217-2.517 5.202-1.026", + teams: + "M20.625 8.127q-.55 0-1.025-.205-.475-.205-.832-.563-.358-.357-.563-.832Q18 6.053 18 5.502q0-.54.205-1.02t.563-.837q.357-.358.832-.563.474-.205 1.025-.205.54 0 1.02.205t.837.563q.358.357.563.837.205.48.205 1.02 0 .55-.205 1.025-.205.475-.563.832-.357.358-.837.563-.48.205-1.02.205zm0-3.75q-.469 0-.797.328-.328.328-.328.797 0 .469.328.797.328.328.797.328.469 0 .797-.328.328-.328.328-.797 0-.469-.328-.797-.328-.328-.797-.328zM24 10.002v5.578q0 .774-.293 1.46-.293.685-.803 1.194-.51.51-1.195.803-.686.293-1.459.293-.445 0-.908-.105-.463-.106-.85-.329-.293.95-.855 1.729-.563.78-1.319 1.336-.756.557-1.67.861-.914.305-1.898.305-1.148 0-2.162-.398-1.014-.399-1.805-1.102-.79-.703-1.312-1.664t-.674-2.086h-5.8q-.411 0-.704-.293T0 16.881V6.873q0-.41.293-.703t.703-.293h8.59q-.34-.715-.34-1.5 0-.727.275-1.365.276-.639.75-1.114.475-.474 1.114-.75.638-.275 1.365-.275t1.365.275q.639.276 1.114.75.474.475.75 1.114.275.638.275 1.365t-.275 1.365q-.276.639-.75 1.113-.475.475-1.114.75-.638.276-1.365.276-.188 0-.375-.024-.188-.023-.375-.058v1.078h10.875q.469 0 .797.328.328.328.328.797zM12.75 2.373q-.41 0-.78.158-.368.158-.638.434-.27.275-.428.639-.158.363-.158.773 0 .41.158.78.159.368.428.638.27.27.639.428.369.158.779.158.41 0 .773-.158.364-.159.64-.428.274-.27.433-.639.158-.369.158-.779 0-.41-.158-.773-.159-.364-.434-.64-.275-.275-.639-.433-.363-.158-.773-.158zM6.937 9.814h2.25V7.94H2.814v1.875h2.25v6h1.875zm10.313 7.313v-6.75H12v6.504q0 .41-.293.703t-.703.293H8.309q.152.809.556 1.5.405.691.985 1.19.58.497 1.318.779.738.281 1.582.281.926 0 1.746-.352.82-.351 1.436-.966.615-.616.966-1.43.352-.815.352-1.752zm5.25-1.547v-5.203h-3.75v6.855q.305.305.691.452.387.146.809.146.469 0 .879-.176.41-.175.715-.48.304-.305.48-.715t.176-.879Z", +}; + +function brandIcon(name) { + const span = el("span", { class: `brand-ico brand-${name}`, "aria-hidden": "true" }); + span.innerHTML = ``; + return span; +} + +function previewChevron() { + return el("span", { + class: "preview-chevron", + "aria-hidden": "true", + html: '', + }); +} + /* ---------------- Motion / view transitions ---------------- */ const reduceMotion = window.matchMedia @@ -478,14 +541,17 @@ function bindCodeHover(node, url) { /* ---------------- Previews ---------------- */ -function labeledCard(name, color, card) { - return el("div", { class: "preview" }, [ - el("div", { class: "preview-label" }, [ - el("span", { class: "dot", style: `background:${color}` }), - name, +function labeledCard(brand, name, card) { + const details = el("details", { class: "preview", open: "" }); + details.appendChild( + el("summary", { class: "preview-label" }, [ + brandIcon(brand), + el("span", { class: "preview-name", text: name }), + previewChevron(), ]), - card, - ]); + ); + details.appendChild(el("div", { class: "preview-body" }, [card])); + return details; } function facebookCard(d, domain) { @@ -501,22 +567,23 @@ function facebookCard(d, domain) { function twitterCard(d, domain) { const isSmall = (d.twitterCard || "").toLowerCase() === "summary"; - if (isSmall) { + // Without an image X always falls back to the small (square-thumb) summary card. + if (isSmall || !d.image) { return el("div", { class: "x small" }, [ makeImage(d.image, "card-img"), el("div", { class: "meta" }, [ - el("div", { class: "domain", text: domain }), el("div", { class: "title", text: d.title || "(no title)" }), d.description ? el("div", { class: "desc", text: d.description }) : null, + el("div", { class: "domain", text: domain }), ]), ]); } - return el("div", { class: "x" }, [ - makeImage(d.image, "card-img"), - d.image ? el("span", { class: "domain-pill", text: domain }) : null, - el("div", { class: "meta" }, [ - el("div", { class: "title", text: d.title || "(no title)" }), - d.description ? el("div", { class: "desc", text: d.description }) : null, + // summary_large_image: X renders just the image with the domain overlaid at + // the bottom-left — it strips the headline and description text entirely. + return el("div", { class: "x large" }, [ + el("div", { class: "x-media" }, [ + makeImage(d.image, "card-img"), + el("span", { class: "domain-pill", text: domain }), ]), ]); } @@ -555,16 +622,40 @@ function discordCard(d, domain) { ]); } +function blueskyCard(d, domain) { + return el("div", { class: "bsky" }, [ + makeImage(d.image, "card-img"), + el("div", { class: "meta" }, [ + el("div", { class: "title", text: d.title || "(no title)" }), + d.description ? el("div", { class: "desc", text: d.description }) : null, + el("div", { class: "domain" }, [octicon("globe", 13, "bsky-globe"), domain]), + ]), + ]); +} + +function teamsCard(d, domain) { + return el("div", { class: "teams" }, [ + el("div", { class: "site" }, [d.favicon ? makeImage(d.favicon, "") : null, d.siteName || domain]), + d.image ? makeImage(d.image, "card-img") : null, + el("div", { class: "meta" }, [ + el("div", { class: "title", text: d.title || "(no title)" }), + d.description ? el("div", { class: "desc", text: d.description }) : null, + ]), + ]); +} + function renderPreviews(data) { const d = data.resolved; const domain = prettyDomain(d.hostname); const grid = $("#previews"); grid.replaceChildren( - labeledCard("OpenGraph · Facebook", "#1877f2", facebookCard(d, domain)), - labeledCard("X · Twitter", "#000000", twitterCard(d, domain)), - labeledCard("LinkedIn", "#0a66c2", linkedinCard(d, domain)), - labeledCard("Slack", "#4a154b", slackCard(d, domain)), - labeledCard("Discord", "#5865f2", discordCard(d, domain)), + labeledCard("facebook", "OpenGraph · Facebook", facebookCard(d, domain)), + labeledCard("x", "X · Twitter", twitterCard(d, domain)), + labeledCard("bluesky", "Bluesky", blueskyCard(d, domain)), + labeledCard("linkedin", "LinkedIn", linkedinCard(d, domain)), + labeledCard("slack", "Slack", slackCard(d, domain)), + labeledCard("teams", "Microsoft Teams", teamsCard(d, domain)), + labeledCard("discord", "Discord", discordCard(d, domain)), ); // Retrigger the staggered entrance animation. grid.classList.remove("enter"); @@ -779,16 +870,17 @@ function kvTable(rows) { function rawChevron() { return el("span", { class: "raw-chevron", - html: '', + html: '', }); } -function rawGroup(title, rows) { +function rawGroup(title, rows, icon) { if (!rows || rows.length === 0) return null; const details = el("details", { class: "raw-group", open: "" }); details.appendChild( el("summary", null, [ rawChevron(), + icon ? octicon(icon, 16, "raw-ico") : null, el("span", { class: "raw-title", text: title }), el("span", { class: "count", text: String(rows.length) }), ]), @@ -804,10 +896,10 @@ function renderRaw(data) { value: i.href, })); host.replaceChildren( - rawGroup("OpenGraph", data.groups.openGraph), - rawGroup("Twitter / X", data.groups.twitter), - rawGroup("Other meta", data.groups.other), - rawGroup("Icons & links", iconRows), + rawGroup("OpenGraph", data.groups.openGraph, "globe"), + rawGroup("Twitter / X", data.groups.twitter, "mention"), + rawGroup("Other meta", data.groups.other, "tag"), + rawGroup("Icons & links", iconRows, "link"), ); if (!host.childElementCount) { host.appendChild(el("p", { class: "muted", text: "No metadata tags found." })); @@ -817,6 +909,135 @@ function renderRaw(data) { /* ---------------- Diagnostics ---------------- */ +// Two-tone semantic status icon: outer shape in the semantic color +// (via currentColor) + inner mark forced white so it reads in dark mode. +const DIAG_SHAPES = { + circle: "M8 0a8 8 0 1 1 0 16A8 8 0 0 1 8 0Z", + check: + "M11.78 5.97a.75.75 0 0 1 0 1.06l-4.25 4.25a.75.75 0 0 1-1.06 0L4.22 9.03a.75.75 0 1 1 1.06-1.06l1.72 1.72 3.72-3.72a.75.75 0 0 1 1.06 0Z", + x: + "M5.72 5.72a.75.75 0 0 1 1.06 0L8 6.94l1.22-1.22a.751.751 0 0 1 1.042.018.751.751 0 0 1 .018 1.042L9.06 8l1.22 1.22a.751.751 0 0 1-.018 1.042.751.751 0 0 1-1.042.018L8 9.06 6.78 10.28a.751.751 0 0 1-1.042-.018.751.751 0 0 1-.018-1.042L6.94 8 5.72 6.78a.75.75 0 0 1 0-1.06Z", + triangle: + "M6.457 1.047c.659-1.234 2.427-1.234 3.086 0l6.082 11.378A1.75 1.75 0 0 1 14.082 15H1.918a1.75 1.75 0 0 1-1.543-2.575Z", + bang: + "M9 11a1 1 0 1 1-2 0 1 1 0 0 1 2 0Zm-.25-5.25a.75.75 0 0 0-1.5 0v2.5a.75.75 0 0 0 1.5 0Z", +}; + +function diagIcon(kind) { + const span = el("span", { class: `diag-ico ${kind}`, "aria-hidden": "true" }); + const outer = kind === "warn" ? DIAG_SHAPES.triangle : DIAG_SHAPES.circle; + const inner = kind === "warn" ? DIAG_SHAPES.bang : kind === "req" ? DIAG_SHAPES.x : DIAG_SHAPES.check; + span.innerHTML = + `` + + `` + + ``; + return span; +} + +function diagChevron() { + return el("span", { + class: "diag-chevron", + "aria-hidden": "true", + html: '', + }); +} + +const OGP = "https://ogp.me/"; +const XCARDS = "https://developer.x.com/en/docs/twitter-for-websites/cards"; + +// Client-side guidance keyed by diagnostic id. Kept here (not in the wire +// payload) so parse-og.mjs stays a pure parser. +const DIAG_HELP = { + "og:title": { + why: "Platforms use og:title as the bold headline of the link card. Without it they fall back to the page or show nothing.", + fix: "Add an og:title in the document <head> with a concise, descriptive headline (~40–60 characters).", + example: '<meta property="og:title" content="Your headline" />', + docs: { href: OGP + "#metadata", label: "Open Graph protocol" }, + }, + "og:type": { + why: "og:type tells platforms what kind of object the page is (website, article, video…), which controls how the card is rendered.", + fix: 'Add og:type — most pages should use "website"; use "article" for posts and news.', + example: '<meta property="og:type" content="website" />', + docs: { href: OGP + "#types", label: "Open Graph object types" }, + }, + "og:image": { + why: "The preview image is the most eye-catching part of a shared link. Without og:image the card renders as plain text.", + fix: "Add og:image pointing to an absolute https URL. Recommended size is 1200×630 (1.91:1).", + example: '<meta property="og:image" content="https://example.com/preview.png" />', + docs: { href: OGP + "#metadata", label: "Open Graph protocol" }, + }, + "og:url": { + why: "og:url is the canonical URL platforms attribute the share to, deduplicating tracking params and URL variants.", + fix: "Add og:url with the clean, canonical absolute URL of the page.", + example: '<meta property="og:url" content="https://example.com/page" />', + docs: { href: OGP + "#metadata", label: "Open Graph protocol" }, + }, + "og:description": { + why: "The description is the supporting copy shown under the title on most platforms.", + fix: "Add og:description with a 1–2 sentence summary (~55–200 characters).", + example: '<meta property="og:description" content="A short summary of the page." />', + docs: { href: OGP + "#metadata", label: "Open Graph protocol" }, + }, + "og:site_name": { + why: "og:site_name labels which site the content belongs to — shown as a small eyebrow on several platforms.", + fix: "Add og:site_name with your site or brand name.", + example: '<meta property="og:site_name" content="Your Site" />', + docs: { href: OGP + "#optional", label: "Open Graph optional metadata" }, + }, + "twitter:card": { + why: "twitter:card selects the X / Twitter card layout. Without it X uses a minimal fallback.", + fix: 'Add twitter:card — use "summary_large_image" when you have a wide preview image, otherwise "summary".', + example: '<meta name="twitter:card" content="summary_large_image" />', + docs: { href: XCARDS, label: "X Cards documentation" }, + }, + "Absolute og:image URL": { + why: "Relative image paths can't be resolved by external crawlers, so the preview image silently fails on most platforms.", + fix: "Use a fully-qualified absolute URL (https://…) for og:image, not a relative path.", + example: '<meta property="og:image" content="https://example.com/preview.png" />', + docs: { href: OGP + "#metadata", label: "Open Graph protocol" }, + }, + "Description length OK": { + why: "Long descriptions get truncated mid-sentence; very short ones look empty. ~55–200 characters renders cleanly across platforms.", + fix: "Trim or expand og:description to roughly 55–200 characters.", + example: '<meta property="og:description" content="A concise, complete summary that fits in about 160 characters." />', + docs: { href: OGP + "#metadata", label: "Open Graph protocol" }, + }, +}; + +function diagDetail(help) { + const detail = el("div", { class: "diag-detail" }); + detail.appendChild( + el("div", { class: "diag-block" }, [ + el("div", { class: "diag-block-h", text: "Why it matters" }), + el("p", { class: "diag-block-p", text: help.why }), + ]), + ); + detail.appendChild( + el("div", { class: "diag-block" }, [ + el("div", { class: "diag-block-h", text: "How to fix it" }), + el("p", { class: "diag-block-p", text: help.fix }), + ]), + ); + if (help.example) { + const snippet = el("div", { class: "diag-snippet" }, [ + el("code", { text: help.example }), + copyButton(help.example, "Copy snippet"), + ]); + detail.appendChild(snippet); + } + if (help.docs) { + const link = el("a", { + class: "diag-docs", + href: help.docs.href, + target: "_blank", + rel: "noreferrer", + }); + link.append(octicon("link-external", 14, "diag-docs-ico"), document.createTextNode(help.docs.label)); + detail.appendChild(link); + } + return detail; +} + function diagnosticCounts(diagnostics) { const counts = { ok: 0, warn: 0, req: 0 }; for (const c of diagnostics || []) { @@ -831,25 +1052,43 @@ function renderDiagnostics(data) { const host = $("#diagnostics"); host.replaceChildren( ...data.diagnostics.map((c) => { - let cls = "ok"; - let glyph = "\u2713"; - if (!c.ok) { - if (c.level === "required") { - cls = "req"; - glyph = "\u2715"; - } else { - cls = "warn"; - glyph = "!"; - } + const kind = c.ok ? "ok" : c.level === "required" ? "req" : "warn"; + const levelText = c.ok ? "passed" : c.level; + + // Passing checks render as plain, non-expandable rows. + if (c.ok) { + return el("div", { class: "diag-item" }, [ + el("div", { class: "diag-row" }, [ + diagIcon(kind), + el("div", { class: "diag-text" }, [ + el("span", { class: "diag-id", text: c.id }), + el("span", { class: `diag-level ${kind}`, text: levelText }), + c.note ? el("div", { class: "diag-note", text: c.note }) : null, + ]), + ]), + ]); } - return el("div", { class: "diag-item" }, [ - el("div", { class: `diag-mark ${cls}`, text: glyph }), - el("div", null, [ - el("span", { class: "diag-id", text: c.id }), - el("span", { class: "diag-level", text: c.level }), - el("div", { class: "muted", text: c.note || "" }), + + // Failing checks expand to show what's wrong and how to fix it. + const help = + DIAG_HELP[c.id] || { + why: c.note || "This recommended metadata is missing or invalid.", + fix: "Add or correct this metadata tag in the document <head>.", + }; + const details = el("details", { class: `diag-item diag-${kind}` }); + details.appendChild( + el("summary", { class: "diag-row" }, [ + diagIcon(kind), + el("div", { class: "diag-text" }, [ + el("span", { class: "diag-id", text: c.id }), + el("span", { class: `diag-level ${kind}`, text: levelText }), + c.note ? el("div", { class: "diag-note", text: c.note }) : null, + ]), + diagChevron(), ]), - ]); + ); + details.appendChild(diagDetail(help)); + return details; }), ); } @@ -944,9 +1183,10 @@ function setStatus(kind, message) { statusEl.textContent = message; } -async function load(rawUrl) { +async function load(rawUrl, opts) { const url = withScheme(rawUrl); if (!url) return; + const silent = !!(opts && opts.silent); hideImgTip(); hideCodeCard(); input.value = url; @@ -956,7 +1196,9 @@ async function load(rawUrl) { // Show realistic shaped skeletons immediately so the layout is stable. renderSkeleton(); try { - const res = await fetch("/api/fetch?u=" + encodeURIComponent(url)); + const res = await fetch( + "/api/fetch?u=" + encodeURIComponent(url) + (silent ? "&silent=1" : ""), + ); const data = await res.json(); if (!res.ok || data.error) { throw new Error(data.error || `Request failed (${res.status})`); @@ -965,6 +1207,8 @@ async function load(rawUrl) { if (data.resolved.url || data.requestedUrl) { input.value = data.requestedUrl || url; } + pendingBrowseUrl = data.requestedUrl || url; + if (!(opts && opts.skipBrowse)) syncBrowseFrame(pendingBrowseUrl); document.title = data.requestedUrl ? `OG · ${(data.resolved && data.resolved.hostname) || data.requestedUrl}` : "OpenGraph Preview"; @@ -1008,18 +1252,149 @@ document.querySelectorAll("[data-example]").forEach((btn) => { }); }); +const TAB_KEY = "og-preview:tab"; +const TAB_ICONS = { previews: "image", raw: "code", diagnostics: "checklist", browse: "globe" }; + +function activateTab(name) { + const tab = document.querySelector(`.tab[data-tab="${name}"]`); + const panel = $("#panel-" + name); + if (!tab || !panel) return; + document.querySelectorAll(".tab").forEach((t) => t.classList.remove("active")); + document.querySelectorAll(".panel").forEach((p) => p.classList.remove("active")); + tab.classList.add("active"); + panel.classList.add("active"); + document.body.classList.toggle("browse-active", name === "browse"); + try { + sessionStorage.setItem(TAB_KEY, name); + } catch { + /* storage unavailable */ + } + if (name === "browse") syncBrowseFrame(pendingBrowseUrl || input.value); +} + document.querySelectorAll(".tab").forEach((tab) => { + const iconName = TAB_ICONS[tab.dataset.tab]; + if (iconName && !tab.querySelector(".octicon")) { + tab.insertBefore(octicon(iconName, 16, "tab-ico"), tab.firstChild); + } tab.addEventListener("click", () => { if (tab.classList.contains("active")) return; - withTransition(() => { - document.querySelectorAll(".tab").forEach((t) => t.classList.remove("active")); - document.querySelectorAll(".panel").forEach((p) => p.classList.remove("active")); - tab.classList.add("active"); - $("#panel-" + tab.dataset.tab).classList.add("active"); - }); + withTransition(() => activateTab(tab.dataset.tab)); }); }); +/* ---------------- Browse tab (live page + route mirroring) ---------------- + A sandboxed iframe renders the live page through the same-origin /api/proxy + (so any site embeds and its in-page navigation can flow back here). Editing + the route or clicking links updates the previews live, and toolbar loads are + mirrored into the frame. */ + +const browseFrame = $("#browse-frame"); +const browseInput = $("#browse-input"); +const browsePanel = $("#panel-browse"); +let browseFrameUrl = ""; // canonical URL the frame currently points at +let pendingBrowseUrl = ""; // latest loaded URL the frame should show +let browseLoadTimer = null; +let browseNavToken = 0; // guards against out-of-order srcdoc fetches + +function canonUrl(u) { + try { + const x = new URL(withScheme(u)); + return (x.protocol + "//" + x.host + x.pathname.replace(/\/+$/, "") + x.search).toLowerCase(); + } catch { + return String(u || "").trim().toLowerCase(); + } +} + +function browseActive() { + return browsePanel.classList.contains("active"); +} + +// Render a URL inside the embedded frame by fetching the proxied HTML and +// inlining it via srcdoc. We deliberately DON'T point the iframe at the loopback +// /api/proxy URL: the canvas host blocks the nested frame from connecting to +// 127.0.0.1 ("refused to connect"). Inlining the already-proxied HTML sidesteps +// that entirely — the fetch runs from our own document (which works), and the +// frame just renders a string. Relative assets resolve via the injected <base>. +async function navBrowseFrame(rawUrl) { + const u = withScheme(rawUrl); + if (!u) return; + browseFrameUrl = canonUrl(u); + browseInput.value = u; + browsePanel.classList.add("has-browse"); + const token = ++browseNavToken; + try { + const res = await fetch("/api/proxy?u=" + encodeURIComponent(u)); + const html = await res.text(); + if (token !== browseNavToken) return; // a newer navigation superseded us + browseFrame.srcdoc = html; + } catch { + if (token !== browseNavToken) return; + browseFrame.srcdoc = + '<!doctype html><meta charset="utf-8"><body style="margin:0;font:14px/1.5 system-ui;padding:28px;color:#8b949e">Couldn\u2019t load this page in the browse view.</body>'; + } +} + +// Mirror the currently-loaded URL into the frame when it's worth doing (Browse +// tab visible or already initialised), skipping a redundant reload. +function syncBrowseFrame(rawUrl) { + const u = withScheme(rawUrl); + if (u) pendingBrowseUrl = u; + if (!pendingBrowseUrl) return; + if (!browseActive() && !browsePanel.classList.contains("has-browse")) return; + if (canonUrl(pendingBrowseUrl) === browseFrameUrl) return; + navBrowseFrame(pendingBrowseUrl); +} + +// Debounced preview refresh driven by genuine in-frame navigation. Silent (never +// re-opens the canvas) and skipBrowse (the message handler already advanced the +// frame, so we don't want load() to re-fetch it). +function scheduleBrowsePreview(url) { + clearTimeout(browseLoadTimer); + browseLoadTimer = setTimeout(() => { + load(url, { silent: true, skipBrowse: true }); + }, 300); +} + +$("#browse-form").addEventListener("submit", (e) => { + e.preventDefault(); + const u = browseInput.value; + if (!u || !u.trim()) return; + input.value = withScheme(u); // keep the top-level URL bound to the route bar + navBrowseFrame(u); + load(u, { silent: true, skipBrowse: true }); +}); + +$("#browse-reload").addEventListener("click", () => { + const u = browseInput.value || pendingBrowseUrl; + if (u && u.trim()) navBrowseFrame(u); +}); + +$("#browse-open").addEventListener("click", () => { + const u = withScheme(browseInput.value || pendingBrowseUrl); + if (!u) return; + try { + window.open(u, "_blank", "noopener"); + } catch { + /* host may block popups */ + } +}); + +// Navigation reported from inside the sandboxed proxy frame. Browsing is the +// primary driver: we bind the landed route to BOTH the route bar and the +// top-level URL input, advance the embedded frame to the new page, and refresh +// every preview from it. +window.addEventListener("message", (e) => { + const m = e && e.data; + if (!m || m.source !== "og-browse" || m.type !== "nav" || !m.url) return; + browseInput.value = m.url; + if (canonUrl(m.url) === browseFrameUrl) return; // echo from our own render + browseFrameUrl = canonUrl(m.url); // genuine in-frame route change + input.value = m.url; // bind the top-level URL to the browsed route + navBrowseFrame(m.url); // advance the embedded frame to the clicked page + scheduleBrowsePreview(m.url); // refresh the previews (skipBrowse) +}); + $("#footer-toggle").addEventListener("click", () => { setFooterOpen($("#footer").dataset.collapsed === "true"); }); @@ -1090,6 +1465,16 @@ if (initial) { load(initial); } +// Default to the Browse tab — it's the primary driver for the previews. +// Restore a different tab only if the user explicitly switched away this session. +try { + const savedTab = sessionStorage.getItem(TAB_KEY); + const startTab = savedTab && $("#panel-" + savedTab) ? savedTab : "browse"; + activateTab(startTab); +} catch { + activateTab("browse"); +} + /* ---------------- Theme awareness for preview cards ---------------- The brand cards mimic each platform, but should still read like that platform's DARK UI when the app is in dark mode (instead of glaring white). diff --git a/.github/extensions/og-preview/ui/index.html b/.github/extensions/og-preview/ui/index.html index fe982b0a3..8ea303190 100644 --- a/.github/extensions/og-preview/ui/index.html +++ b/.github/extensions/og-preview/ui/index.html @@ -38,7 +38,8 @@ </form> <div class="toolbar-row"> <nav class="tabs" role="tablist"> - <button class="tab active" data-tab="previews" role="tab">Previews</button> + <button class="tab active" data-tab="browse" role="tab">Browse</button> + <button class="tab" data-tab="previews" role="tab">Previews</button> <button class="tab" data-tab="raw" role="tab">Raw metadata</button> <button class="tab" data-tab="diagnostics" role="tab">Diagnostics</button> </nav> @@ -52,7 +53,7 @@ <div id="status" class="status" hidden></div> <main class="content"> - <section id="panel-previews" class="panel active"> + <section id="panel-previews" class="panel"> <div id="previews" class="previews-grid"></div> </section> <section id="panel-raw" class="panel"> @@ -65,6 +66,63 @@ <section id="panel-diagnostics" class="panel"> <div id="diagnostics"></div> </section> + <section id="panel-browse" class="panel browse-panel active"> + <div class="browse-bar"> + <button id="browse-reload" class="btn btn-ghost btn-icon" type="button" title="Reload page" aria-label="Reload page"> + <svg width="15" height="15" viewBox="0 0 16 16" aria-hidden="true"> + <path + fill="currentColor" + d="M8 2.5a5.5 5.5 0 1 0 5.39 6.6.75.75 0 0 1 1.47.3A7 7 0 1 1 8 1c1.74 0 3.32.64 4.53 1.69V1.75a.75.75 0 0 1 1.5 0v3a.75.75 0 0 1-.75.75h-3a.75.75 0 0 1 0-1.5h1.36A5.48 5.48 0 0 0 8 2.5Z" + /> + </svg> + </button> + <form id="browse-form" class="browse-form" autocomplete="off"> + <div class="browse-input-wrap"> + <svg class="browse-input-icon" width="13" height="13" viewBox="0 0 16 16" aria-hidden="true"> + <path + fill="currentColor" + d="M7.775 3.275a.75.75 0 0 0 1.06 1.06l1.25-1.25a2 2 0 1 1 2.83 2.83l-2.5 2.5a2 2 0 0 1-2.83 0 .75.75 0 0 0-1.06 1.06 3.5 3.5 0 0 0 4.95 0l2.5-2.5a3.5 3.5 0 0 0-4.95-4.95l-1.25 1.25Zm-1.55 9.45a.75.75 0 0 0-1.06-1.06l-1.25 1.25a2 2 0 1 1-2.83-2.83l2.5-2.5a2 2 0 0 1 2.83 0 .75.75 0 0 0 1.06-1.06 3.5 3.5 0 0 0-4.95 0l-2.5 2.5a3.5 3.5 0 0 0 4.95 4.95l1.25-1.25Z" + /> + </svg> + <input + id="browse-input" + class="browse-input" + type="text" + spellcheck="false" + placeholder="Edit the route, or click links in the page below…" + aria-label="Browse route" + /> + </div> + </form> + <button id="browse-open" class="btn btn-ghost btn-icon" type="button" title="Open in browser" aria-label="Open in browser"> + <svg width="15" height="15" viewBox="0 0 16 16" aria-hidden="true"> + <path + fill="currentColor" + d="M3.75 2A1.75 1.75 0 0 0 2 3.75v8.5C2 13.216 2.784 14 3.75 14h8.5A1.75 1.75 0 0 0 14 12.25v-3.5a.75.75 0 0 0-1.5 0v3.5a.25.25 0 0 1-.25.25h-8.5a.25.25 0 0 1-.25-.25v-8.5a.25.25 0 0 1 .25-.25h3.5a.75.75 0 0 0 0-1.5h-3.5Z" + /> + <path + fill="currentColor" + d="M9.75 2a.75.75 0 0 0 0 1.5h1.69L7.22 7.72a.75.75 0 1 0 1.06 1.06l4.22-4.22v1.69a.75.75 0 0 0 1.5 0v-3.5a.75.75 0 0 0-.75-.75h-3.5Z" + /> + </svg> + </button> + </div> + <div class="browse-stage"> + <iframe + id="browse-frame" + class="browse-frame" + title="Live page preview" + sandbox="allow-scripts allow-forms allow-popups allow-popups-to-escape-sandbox" + referrerpolicy="no-referrer" + ></iframe> + <div id="browse-empty" class="browse-empty"> + <p class="muted"> + Load a URL to browse the live page here. Click links or edit the + route above and the previews update automatically. + </p> + </div> + </div> + </section> <div id="empty" class="empty"> <div class="empty-icon" aria-hidden="true"> @@ -94,8 +152,8 @@ aria-expanded="false" aria-controls="footer-body" > - <svg class="footer-chevron" width="14" height="14" viewBox="0 0 16 16" aria-hidden="true"> - <path fill="currentColor" d="M6 4l4 4-4 4V4z" /> + <svg class="footer-chevron" width="12" height="12" viewBox="0 0 16 16" fill="none" aria-hidden="true"> + <path d="M6 4l4 4-4 4" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round" /> </svg> <span id="footer-summary" class="footer-summary muted">No page loaded</span> <span class="footer-hint muted">Page info</span> diff --git a/.github/extensions/og-preview/ui/styles.css b/.github/extensions/og-preview/ui/styles.css index b2d844414..3ce29e055 100644 --- a/.github/extensions/og-preview/ui/styles.css +++ b/.github/extensions/og-preview/ui/styles.css @@ -13,6 +13,11 @@ --radius-lg: 8px; --radius-xl: 10px; + /* Outer gutter — the single left/right inset shared by the toolbar, status + banner, content and footer so every edge lines up (and matches the app's + panel inset, which is tighter than a default 16px). */ + --gutter: 8px; + /* On-theme accent. Prefer the app's focus/accent token; fall back to the documented blue true-color, then a literal. */ --accent: var(--color-focus-outline, var(--true-color-blue, #0969da)); @@ -32,6 +37,13 @@ --border-soft: color-mix(in srgb, var(--text-color-default, #1f2328) 14%, transparent); --accent-soft: color-mix(in srgb, var(--accent) 14%, transparent); + /* Thin app-style scrollbar thumb (neutral, derived from text color so it + inverts in dark mode). First value is a non-color-mix fallback. */ + --scrollbar-thumb: rgba(140, 149, 159, 0.5); + --scrollbar-thumb: color-mix(in srgb, var(--text-color-default, #1f2328) 30%, transparent); + --scrollbar-thumb-hover: rgba(140, 149, 159, 0.75); + --scrollbar-thumb-hover: color-mix(in srgb, var(--text-color-default, #1f2328) 46%, transparent); + --shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.08), 0 1px 1px rgba(0, 0, 0, 0.04); --shadow-pop: 0 1px 1px rgba(0, 0, 0, 0.04), 0 4px 12px rgba(0, 0, 0, 0.12); } @@ -48,6 +60,38 @@ display: none !important; } +/* App-style scrollbars: thin, rounded thumb, transparent track, and NO stepper + (up/down chevron) buttons at the ends. Applied to every scroll container. */ +* { + scrollbar-width: thin; + scrollbar-color: var(--scrollbar-thumb) transparent; +} +*::-webkit-scrollbar { + width: 11px; + height: 11px; +} +*::-webkit-scrollbar-track { + background: transparent; +} +*::-webkit-scrollbar-thumb { + background: var(--scrollbar-thumb); + border-radius: 999px; + border: 2px solid transparent; + background-clip: padding-box; +} +*::-webkit-scrollbar-thumb:hover { + background: var(--scrollbar-thumb-hover); + background-clip: padding-box; +} +*::-webkit-scrollbar-button { + display: none; + width: 0; + height: 0; +} +*::-webkit-scrollbar-corner { + background: transparent; +} + html, body { margin: 0; @@ -154,7 +198,7 @@ code { position: sticky; top: 0; z-index: 5; - padding: 12px 16px; + padding: 12px var(--gutter); background: var(--background-color-default, #ffffff); border-bottom: 1px solid var(--border-color-default, #d1d9e0); } @@ -213,6 +257,18 @@ code { margin-top: 12px; } +/* Inline Octicons */ +.octicon { + display: inline-flex; + flex: 0 0 auto; + align-items: center; + justify-content: center; +} +.octicon svg { + display: block; + fill: currentColor; +} + /* Segmented tabs */ .tabs { display: inline-flex; @@ -233,9 +289,21 @@ code { font-weight: var(--font-weight-semibold, 600); font-family: inherit; cursor: pointer; + display: inline-flex; + align-items: center; + gap: 6px; transition: background-color 0.12s ease, color 0.12s ease, box-shadow 0.12s ease; } +.tab-ico { + color: var(--text-color-muted, #59636e); + transition: color 0.12s ease; +} +.tab:hover .tab-ico, +.tab.active .tab-ico { + color: inherit; +} + .tab:hover { color: var(--text-color-default, #1f2328); } @@ -291,7 +359,7 @@ code { /* ---------- Status ---------- */ .status { - margin: 12px 16px 0; + margin: 10px var(--gutter) 0; padding: 10px 14px; border-radius: var(--radius-lg); font-size: 13px; @@ -332,7 +400,7 @@ code { .content { flex: 1; overflow: auto; - padding: 16px; + padding: 14px var(--gutter); display: flex; flex-direction: column; } @@ -375,16 +443,115 @@ body.is-busy .empty { display: none; } +/* ---------------- Browse tab ---------------- */ +body.browse-active .empty { + display: none; +} +#panel-browse.active { + flex: 1; + min-height: 0; + display: flex; + flex-direction: column; + gap: 10px; +} +.browse-bar { + display: flex; + align-items: center; + gap: 8px; +} +.browse-form { + flex: 1; + min-width: 0; +} +.browse-input-wrap { + position: relative; + display: flex; + align-items: center; +} +.browse-input-icon { + position: absolute; + left: 9px; + color: var(--text-color-muted, #59636e); + pointer-events: none; +} +.browse-input { + width: 100%; + font-family: var(--font-mono, "SFMono-Regular", Consolas, monospace); + font-size: 12.5px; + color: var(--text-color-default, #1f2328); + background: var(--surface-inset); + border: 1px solid var(--border-soft); + border-radius: var(--radius); + padding: 6px 10px 6px 28px; + outline: none; +} +.browse-input:focus { + border-color: var(--accent); + box-shadow: 0 0 0 3px var(--accent-soft); +} +.browse-stage { + position: relative; + flex: 1; + min-height: 0; + border: 1px solid var(--border-soft); + border-radius: var(--radius-lg); + overflow: hidden; + background: var(--surface-raised); +} +.browse-frame { + position: absolute; + inset: 0; + width: 100%; + height: 100%; + border: 0; + background: #ffffff; + display: none; +} +.browse-panel.has-browse .browse-frame { + display: block; +} +.browse-empty { + position: absolute; + inset: 0; + display: flex; + align-items: center; + justify-content: center; + text-align: center; + padding: 28px; +} +.browse-panel.has-browse .browse-empty { + display: none; +} +.browse-empty p { + max-width: 360px; + margin: 0; +} + /* ---------- Previews grid ---------- */ +/* Multi-column (masonry-style) packing so short cards don't leave the vertical + gaps a rigid grid row creates when a tall card sets the row height. */ .previews-grid { - display: grid; - grid-template-columns: repeat(auto-fill, minmax(360px, 1fr)); - gap: 20px; - align-items: start; + column-width: 360px; + column-gap: 20px; } .preview { min-width: 0; + break-inside: avoid; + margin-bottom: 20px; + border: 1px solid var(--border-soft); + border-radius: 12px; + padding: 6px 8px 8px; + background: var(--surface-raised); + transition: + border-color 0.16s ease, + box-shadow 0.16s ease; +} +.preview:hover { + border-color: var(--border-color-default, #d1d9e0); +} +.preview[open] { + box-shadow: var(--shadow-sm); } .preview-label { @@ -394,7 +561,109 @@ body.is-busy .empty { font-size: 12.5px; font-weight: var(--font-weight-semibold, 600); color: var(--text-color-muted, #59636e); - margin-bottom: 8px; + margin-bottom: 0; + padding: 5px 6px; + border-radius: 8px; + cursor: pointer; + list-style: none; + user-select: none; + transition: + background 0.14s ease, + color 0.14s ease; +} +.preview-label::-webkit-details-marker, +.preview-label::marker { + display: none; + content: ""; +} +.preview > summary.preview-label:hover { + color: var(--text-color-default, #1f2328); + background: var(--surface-inset); +} +.preview > summary.preview-label:focus-visible { + outline: 2px solid var(--accent); + outline-offset: 2px; + border-radius: var(--radius); +} + +.preview-chevron { + display: inline-flex; + flex: 0 0 auto; + margin-left: auto; + color: var(--text-color-muted, #59636e); + transition: transform 0.22s cubic-bezier(0.22, 0.61, 0.36, 1); +} +.preview[open] > summary .preview-chevron { + transform: rotate(90deg); +} + +/* Smoothly expand/collapse the body. grid-template-rows 0fr->1fr animates the + height; content-visibility transitions discretely so the close animation + isn't cut short by the UA hiding the content immediately. */ +.preview::details-content { + display: grid; + grid-template-rows: 0fr; + overflow: hidden; + opacity: 0; + transition: + grid-template-rows 0.28s cubic-bezier(0.22, 0.61, 0.36, 1), + opacity 0.22s ease, + content-visibility 0.28s allow-discrete; +} +.preview[open]::details-content { + grid-template-rows: 1fr; + opacity: 1; +} +.preview-body { + min-height: 0; + margin-top: 8px; +} + +.preview-name { + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +/* Brand marks in the preview labels (logo color per platform). */ +.brand-ico { + display: inline-flex; + flex: 0 0 auto; +} +.brand-ico svg { + display: block; +} +.brand-facebook { + color: #1877f2; +} +.brand-x { + color: #0f1419; +} +.brand-linkedin { + color: #0a66c2; +} +.brand-slack { + color: #4a154b; +} +.brand-discord { + color: #5865f2; +} +.brand-bluesky { + color: #0285ff; +} +.brand-teams { + color: #6264a7; +} +/* Keep dark / aubergine logos legible on a dark canvas. */ +body[data-mode="dark"] .brand-x { + color: #e7e9ea; +} +body[data-mode="dark"] .brand-slack { + color: #d9a8d3; +} +body[data-mode="dark"] .brand-teams { + color: #8b8cc7; } .preview-label .dot { @@ -442,7 +711,7 @@ body.is-busy .empty { color: #606770; } -/* X / Twitter large */ +/* X / Twitter large (summary_large_image) — image with domain overlay only */ .x { border: 1px solid #cfd9de; border-radius: 16px; @@ -451,26 +720,33 @@ body.is-busy .empty { color: #0f1419; position: relative; } +.x .x-media { + position: relative; + display: block; +} .x .card-img { aspect-ratio: 1.91 / 1; } .x .domain-pill { position: absolute; left: 12px; - bottom: 56px; - background: rgba(0, 0, 0, 0.72); + bottom: 12px; + background: rgba(0, 0, 0, 0.7); color: #fff; font-size: 12px; + line-height: 1.4; padding: 1px 6px; border-radius: 4px; + backdrop-filter: blur(2px); } .x .meta { padding: 10px 12px; } .x .title { font-size: 15px; - font-weight: 400; + font-weight: 700; color: #0f1419; + line-height: 1.3; } .x .desc { font-size: 14px; @@ -478,10 +754,11 @@ body.is-busy .empty { margin-top: 2px; } .x .domain { - font-size: 14px; + font-size: 13px; color: #536471; + margin-top: 4px; } -/* X small (summary) */ +/* X small (summary) — square thumbnail beside the text */ .x.small { display: flex; align-items: stretch; @@ -591,6 +868,80 @@ body.is-busy .empty { aspect-ratio: auto; } +/* Bluesky (external embed card) */ +.bsky { + border: 1px solid #d4dbe2; + border-radius: 12px; + overflow: hidden; + background: #fff; + color: #0b0f14; +} +.bsky .meta { + padding: 10px 12px; +} +.bsky .title { + font-size: 15px; + font-weight: 600; + line-height: 1.3; +} +.bsky .desc { + font-size: 14px; + color: #42576c; + margin-top: 2px; +} +.bsky .domain { + display: flex; + align-items: center; + gap: 5px; + font-size: 13px; + color: #788fa5; + margin-top: 9px; + padding-top: 9px; + border-top: 1px solid #e3e8ee; +} + +/* Microsoft Teams (link unfurl card) */ +.teams { + border: 1px solid #e1dfdd; + border-radius: 8px; + overflow: hidden; + background: #fff; + color: #242424; + box-shadow: 0 1.6px 3.6px rgba(0, 0, 0, 0.08), 0 0.3px 0.9px rgba(0, 0, 0, 0.06); +} +.teams .site { + display: flex; + align-items: center; + gap: 6px; + padding: 10px 12px 0; + font-size: 12px; + font-weight: 600; + color: #616161; +} +.teams .site img { + width: 16px; + height: 16px; + border-radius: 3px; +} +.teams .card-img { + margin-top: 10px; + aspect-ratio: 1.91 / 1; +} +.teams .meta { + padding: 8px 12px 12px; +} +.teams .title { + font-size: 15px; + font-weight: 600; + color: #242424; + line-height: 1.3; +} +.teams .desc { + font-size: 14px; + color: #616161; + margin-top: 3px; +} + .card-missing { padding: 14px; font-size: 13px; @@ -662,6 +1013,35 @@ body[data-mode="dark"] .slack .desc { color: #d1d2d3; } +body[data-mode="dark"] .bsky { + background: #161e27; + color: #e6edf3; + border-color: #2b3a4a; +} +body[data-mode="dark"] .bsky .desc { + color: #9fb0c0; +} +body[data-mode="dark"] .bsky .domain { + color: #8298ac; + border-top-color: #24303c; +} + +body[data-mode="dark"] .teams { + background: #2b2b2b; + color: #f5f5f5; + border-color: #3d3d3d; + box-shadow: none; +} +body[data-mode="dark"] .teams .site { + color: #c8c6c4; +} +body[data-mode="dark"] .teams .title { + color: #f5f5f5; +} +body[data-mode="dark"] .teams .desc { + color: #c8c6c4; +} + /* ---------- Raw ---------- */ .raw-actions { display: flex; @@ -718,7 +1098,7 @@ body[data-mode="dark"] .slack .desc { .raw-chevron { display: inline-flex; color: var(--text-color-muted, #59636e); - transition: transform 0.15s ease; + transition: transform 0.22s cubic-bezier(0.22, 0.61, 0.36, 1); flex: 0 0 auto; } @@ -730,6 +1110,11 @@ body[data-mode="dark"] .slack .desc { font-weight: var(--font-weight-semibold, 600); } +.raw-ico { + color: var(--text-color-muted, #59636e); + flex: 0 0 auto; +} + .raw-group .count { margin-left: auto; font-weight: 400; @@ -1121,37 +1506,61 @@ table.kv td.v a:hover { } .diag-item { + display: block; +} + +.diag-item + .diag-item { + border-top: 1px solid var(--border-soft); +} + +.diag-row { display: flex; align-items: flex-start; gap: 10px; padding: 11px 14px; + list-style: none; } -.diag-item + .diag-item { - border-top: 1px solid var(--border-soft); +summary.diag-row { + cursor: pointer; + user-select: none; +} +summary.diag-row::-webkit-details-marker, +summary.diag-row::marker { + display: none; + content: ""; +} +details.diag-item > summary.diag-row:hover { + background: var(--surface-inset); +} +summary.diag-row:focus-visible { + outline: 2px solid var(--accent); + outline-offset: -2px; } -.diag-mark { - width: 20px; - height: 20px; - border-radius: 50%; - display: flex; - align-items: center; - justify-content: center; - font-size: 12px; - flex: 0 0 20px; - color: #fff; +.diag-ico { + flex: 0 0 16px; + width: 16px; + height: 16px; margin-top: 1px; + display: inline-flex; } - -.diag-mark.ok { - background: var(--true-color-green, #1a7f37); +.diag-ico svg { + display: block; } -.diag-mark.warn { - background: var(--true-color-orange, #bc4c00); +.diag-ico.ok { + color: var(--true-color-green, #1a7f37); } -.diag-mark.req { - background: var(--true-color-red, #cf222e); +.diag-ico.warn { + color: var(--true-color-orange, #bc4c00); +} +.diag-ico.req { + color: var(--true-color-red, #cf222e); +} + +.diag-text { + flex: 1 1 auto; + min-width: 0; } .diag-id { @@ -1162,8 +1571,102 @@ table.kv td.v a:hover { font-size: 11px; text-transform: capitalize; letter-spacing: 0.01em; + margin-left: 8px; + padding: 1px 7px; + border-radius: 999px; + border: 1px solid var(--border-soft); color: var(--text-color-muted, #59636e); - margin-left: 6px; +} +.diag-level.ok { + color: var(--true-color-green, #1a7f37); + border-color: color-mix(in srgb, var(--true-color-green, #1a7f37) 35%, transparent); +} +.diag-level.warn { + color: var(--true-color-orange, #bc4c00); + border-color: color-mix(in srgb, var(--true-color-orange, #bc4c00) 40%, transparent); +} +.diag-level.req { + color: var(--true-color-red, #cf222e); + border-color: color-mix(in srgb, var(--true-color-red, #cf222e) 40%, transparent); +} + +.diag-note { + color: var(--text-color-muted, #59636e); + margin-top: 3px; + font-size: 13px; +} + +.diag-chevron { + flex: 0 0 auto; + margin-top: 2px; + color: var(--text-color-muted, #59636e); + transition: transform 0.22s cubic-bezier(0.22, 0.61, 0.36, 1); +} +details.diag-item[open] > summary .diag-chevron { + transform: rotate(90deg); +} + +.diag-detail { + padding: 2px 14px 14px 40px; +} + +.diag-block + .diag-block { + margin-top: 9px; +} +.diag-block-h { + font-size: 11px; + font-weight: var(--font-weight-semibold, 600); + letter-spacing: 0.02em; + text-transform: uppercase; + color: var(--text-color-muted, #59636e); + margin-bottom: 2px; +} +.diag-block-p { + margin: 0; + color: var(--text-color-default, #1f2328); +} + +.diag-snippet { + position: relative; + margin-top: 11px; + background: var(--surface-inset); + border: 1px solid var(--border-soft); + border-radius: var(--radius); + padding: 9px 42px 9px 11px; + overflow-x: auto; +} +.diag-snippet code { + font-family: var(--font-mono, "SFMono-Regular", Consolas, "Liberation Mono", monospace); + font-size: 12px; + white-space: pre; + color: var(--text-color-default, #1f2328); +} +.diag-snippet .copy-btn { + position: absolute; + top: 6px; + right: 6px; + opacity: 0.55; +} +.diag-snippet:hover .copy-btn, +.diag-snippet .copy-btn:focus-visible { + opacity: 1; +} + +.diag-docs { + display: inline-flex; + align-items: center; + gap: 5px; + margin-top: 12px; + font-size: 13px; + font-weight: var(--font-weight-semibold, 600); + color: var(--accent); + text-decoration: none; +} +.diag-docs:hover { + text-decoration: underline; +} +.diag-docs-ico { + color: inherit; } /* ---------- Collapsible footer (page info) ---------- */ @@ -1178,7 +1681,7 @@ table.kv td.v a:hover { display: flex; align-items: center; gap: 8px; - padding: 8px 16px; + padding: 8px var(--gutter); background: transparent; border: none; color: inherit; @@ -1198,7 +1701,7 @@ table.kv td.v a:hover { .footer-chevron { color: var(--text-color-muted, #59636e); - transition: transform 0.15s ease; + transition: transform 0.22s cubic-bezier(0.22, 0.61, 0.36, 1); flex: 0 0 auto; } @@ -1223,7 +1726,7 @@ table.kv td.v a:hover { } .footer-body { - padding: 4px 16px 14px; + padding: 4px var(--gutter) 14px; display: flex; flex-direction: column; gap: 10px; From 2cac4180ace17a656621614586fc4822b28fb54d Mon Sep 17 00:00:00 2001 From: David Pine <7679720+IEvangelist@users.noreply.github.com> Date: Tue, 30 Jun 2026 09:47:35 -0500 Subject: [PATCH 06/21] Browse iframe JS execution, previews layout toggle, language-tab persistence - Run JS inside the Browse iframe safely: inject in-memory localStorage/ sessionStorage/cookie shims so opaque-origin boot code stops throwing, and harden the sandbox to "allow-scripts allow-forms". - Unify the Browse route input with the top URL input styling; bump the browse icon to 15px. - Fix about:srcdoc navigation: resolve about: URLs' query/hash back onto the real page URL in the bridge, and ignore non-http nav targets in the parent. - Add a Previews layout toggle: List (default, larger single column) vs Grid (compact masonry), persisted to localStorage. - Persist in-frame client state (e.g. aspire.dev's selected language tab): tag pushState/replaceState navigations as "soft" so the parent refreshes previews without reloading the frame, while link clicks ("hard") still re-render it. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .github/extensions/og-preview/extension.mjs | 30 +++++++-- .github/extensions/og-preview/ui/app.js | 58 +++++++++++++++- .github/extensions/og-preview/ui/index.html | 12 +++- .github/extensions/og-preview/ui/styles.css | 75 ++++++++++++++++++--- 4 files changed, 157 insertions(+), 18 deletions(-) diff --git a/.github/extensions/og-preview/extension.mjs b/.github/extensions/og-preview/extension.mjs index fa901a914..631dee9c6 100644 --- a/.github/extensions/og-preview/extension.mjs +++ b/.github/extensions/og-preview/extension.mjs @@ -96,8 +96,27 @@ function browseBridgeScript(finalUrl, proxyBase) { return `<script>(function(){ var REAL=${real}; var PROXY=${base}; -function post(u){try{parent.postMessage({source:"og-browse",type:"nav",url:u},"*");}catch(e){}} +/* This frame is sandboxed without allow-same-origin (so it can't escape to the + host canvas), which gives it an opaque origin. On an opaque origin, touching + localStorage / sessionStorage / document.cookie throws SecurityError, which + crashes most frameworks during boot and stops hydration. Shim them with + in-memory stores so JS-driven content can render. */ +function memStore(){var m={};var api={getItem:function(k){return Object.prototype.hasOwnProperty.call(m,k)?m[k]:null;},setItem:function(k,v){m[String(k)]=String(v);},removeItem:function(k){delete m[String(k)];},clear:function(){for(var k in m){if(Object.prototype.hasOwnProperty.call(m,k))delete m[k];}},key:function(i){return Object.keys(m)[i]||null;}};try{Object.defineProperty(api,"length",{get:function(){return Object.keys(m).length;}});}catch(_){}return api;} +function shimStore(name){try{window[name].getItem("__og_probe__");return;}catch(e){}try{Object.defineProperty(window,name,{value:memStore(),configurable:true});}catch(_){}} +shimStore("localStorage");shimStore("sessionStorage"); +try{document.cookie;}catch(ce){var _ck="";try{Object.defineProperty(document,"cookie",{configurable:true,get:function(){return _ck;},set:function(v){var p=String(v).split(";")[0];if(p)_ck=_ck?_ck+"; "+p:p;}});}catch(_){}} +function post(u,mode){try{parent.postMessage({source:"og-browse",type:"nav",url:u,mode:mode||"hard"},"*");}catch(e){}} function px(a){return PROXY+"?u="+encodeURIComponent(a);} +/* Map a navigation target to a real http(s) URL. SPAs often build URLs from + window.location (which is about:srcdoc in this frame), yielding values like + "about:srcdoc?aspire-lang=csharp". Re-apply such a URL's query/hash onto the + real page URL so navigation points at a real page, not about:srcdoc. */ +function resolveNav(u){ + var x;try{x=new URL(String(u),REAL);}catch(_){return null;} + if(/^https?:$/i.test(x.protocol))return x.toString(); + if(x.protocol==="about:"){try{var b=new URL(REAL);b.search=x.search||"";b.hash=x.hash||"";return b.toString();}catch(_){return null;}} + return null; +} document.addEventListener("click",function(e){ if(e.defaultPrevented||e.button!==0||e.metaKey||e.ctrlKey||e.shiftKey||e.altKey)return; var a=e.target&&e.target.closest?e.target.closest("a[href]"):null; @@ -106,13 +125,12 @@ document.addEventListener("click",function(e){ if(!href||href.charAt(0)==="#")return; if(/^(mailto:|tel:|javascript:|data:)/i.test(href))return; if(a.target&&a.target!==""&&a.target!=="_self")return; - var abs;try{abs=new URL(a.href,REAL).toString();}catch(_){return;} - if(!/^https?:/i.test(abs))return; - e.preventDefault();post(abs); + var abs=resolveNav(a.href);if(!abs)return; + e.preventDefault();post(abs,"hard"); },true); -function wrap(n){var o=history[n];if(typeof o!=="function")return;history[n]=function(){var r=o.apply(this,arguments);try{var u=arguments[2];if(u!=null)post(new URL(String(u),REAL).toString());}catch(_){}return r;};} +function wrap(n){var o=history[n];if(typeof o!=="function")return;history[n]=function(){var r=o.apply(this,arguments);try{var u=arguments[2];if(u!=null){var nav=resolveNav(u);if(nav)post(nav,"soft");}}catch(_){}return r;};} wrap("pushState");wrap("replaceState"); -if(document.readyState==="loading")document.addEventListener("DOMContentLoaded",function(){post(REAL);});else post(REAL); +if(document.readyState==="loading")document.addEventListener("DOMContentLoaded",function(){post(REAL,"init");});else post(REAL,"init"); })();<\/script>`; } diff --git a/.github/extensions/og-preview/ui/app.js b/.github/extensions/og-preview/ui/app.js index fb56cadce..5618ecf57 100644 --- a/.github/extensions/og-preview/ui/app.js +++ b/.github/extensions/og-preview/ui/app.js @@ -51,6 +51,10 @@ const OCTICONS = { '<path fill="currentColor" d="m7.775 3.275 1.25-1.25a3.5 3.5 0 1 1 4.95 4.95l-2.5 2.5a3.5 3.5 0 0 1-4.95 0 .751.751 0 0 1 .018-1.042.751.751 0 0 1 1.042-.018 2 2 0 0 0 2.83 0l2.5-2.5a2.002 2.002 0 0 0-2.83-2.83l-1.25 1.25a.751.751 0 0 1-1.042-.018.751.751 0 0 1-.018-1.042Zm-4.69 9.64a2 2 0 0 0 2.83 0l1.25-1.25a.751.751 0 0 1 1.042.018.751.751 0 0 1 .018 1.042l-1.25 1.25a3.5 3.5 0 1 1-4.95-4.95l2.5-2.5a3.5 3.5 0 0 1 4.95 0 .751.751 0 0 1-.018 1.042.751.751 0 0 1-1.042.018 2 2 0 0 0-2.83 0l-2.5 2.5a2 2 0 0 0 0 2.83Z"/>', "link-external": '<path fill="currentColor" d="M3.75 2A1.75 1.75 0 0 0 2 3.75v8.5C2 13.22 2.78 14 3.75 14h8.5A1.75 1.75 0 0 0 14 12.25v-3a.75.75 0 0 0-1.5 0v3a.25.25 0 0 1-.25.25h-8.5a.25.25 0 0 1-.25-.25v-8.5a.25.25 0 0 1 .25-.25h3a.75.75 0 0 0 0-1.5h-3Z"/><path fill="currentColor" d="M8.5 1.75A.75.75 0 0 1 9.25 1h5a.75.75 0 0 1 .75.75v5a.75.75 0 0 1-1.5 0V3.56L8.78 8.28a.75.75 0 1 1-1.06-1.06l4.72-4.72H9.25a.75.75 0 0 1-.75-.75Z"/>', + "list-unordered": + '<path fill="currentColor" d="M5.75 2.5h8.5a.75.75 0 0 1 0 1.5h-8.5a.75.75 0 0 1 0-1.5Zm0 5h8.5a.75.75 0 0 1 0 1.5h-8.5a.75.75 0 0 1 0-1.5Zm0 5h8.5a.75.75 0 0 1 0 1.5h-8.5a.75.75 0 0 1 0-1.5ZM2 14a1 1 0 1 1 0-2 1 1 0 0 1 0 2Zm1-6a1 1 0 1 1-2 0 1 1 0 0 1 2 0ZM2 4a1 1 0 1 1 0-2 1 1 0 0 1 0 2Z"/>', + apps: + '<path fill="currentColor" d="M2.75 2.5a.25.25 0 0 0-.25.25v1.5c0 .138.112.25.25.25h1.5a.25.25 0 0 0 .25-.25v-1.5a.25.25 0 0 0-.25-.25Zm-1.75.25C1 1.784 1.784 1 2.75 1h1.5c.966 0 1.75.784 1.75 1.75v1.5A1.75 1.75 0 0 1 4.25 6h-1.5A1.75 1.75 0 0 1 1 4.25Zm9-.25a.25.25 0 0 0-.25.25v1.5c0 .138.112.25.25.25h1.5a.25.25 0 0 0 .25-.25v-1.5a.25.25 0 0 0-.25-.25ZM10 2.75C10 1.784 10.784 1 11.75 1h1.5C14.216 1 15 1.784 15 2.75v1.5A1.75 1.75 0 0 1 13.25 6h-1.5A1.75 1.75 0 0 1 10 4.25Zm-7.25 7.75a.25.25 0 0 0-.25.25v1.5c0 .138.112.25.25.25h1.5a.25.25 0 0 0 .25-.25v-1.5a.25.25 0 0 0-.25-.25ZM1 11.75C1 10.784 1.784 10 2.75 10h1.5c.966 0 1.75.784 1.75 1.75v1.5A1.75 1.75 0 0 1 4.25 15h-1.5A1.75 1.75 0 0 1 1 13.25Zm10.75-1.75a.25.25 0 0 0-.25.25v1.5c0 .138.112.25.25.25h1.5a.25.25 0 0 0 .25-.25v-1.5a.25.25 0 0 0-.25-.25ZM10 11.75c0-.966.784-1.75 1.75-1.75h1.5c.966 0 1.75.784 1.75 1.75v1.5A1.75 1.75 0 0 1 13.25 15h-1.5A1.75 1.75 0 0 1 10 13.25Z"/>', }; function octicon(name, size, cls) { @@ -1283,6 +1287,49 @@ document.querySelectorAll(".tab").forEach((tab) => { }); }); +/* ---------------- Previews layout toggle (List default / Grid compact) ---- + The #previews element is persistent (renderPreviews only swaps its children), + so a layout-* class set here survives re-renders. */ +const LAYOUT_KEY = "og-preview:layout"; + +function setPreviewsLayout(mode) { + const m = mode === "grid" ? "grid" : "list"; + const grid = $("#previews"); + if (grid) { + grid.classList.toggle("layout-grid", m === "grid"); + grid.classList.toggle("layout-list", m === "list"); + } + const lb = $("#layout-list"); + const gb = $("#layout-grid"); + if (lb && gb) { + lb.classList.toggle("active", m === "list"); + gb.classList.toggle("active", m === "grid"); + lb.setAttribute("aria-pressed", String(m === "list")); + gb.setAttribute("aria-pressed", String(m === "grid")); + } + try { + localStorage.setItem(LAYOUT_KEY, m); + } catch { + /* storage unavailable */ + } +} + +(function initPreviewsLayout() { + const lb = $("#layout-list"); + const gb = $("#layout-grid"); + if (lb && !lb.querySelector(".octicon")) lb.appendChild(octicon("list-unordered", 16)); + if (gb && !gb.querySelector(".octicon")) gb.appendChild(octicon("apps", 16)); + if (lb) lb.addEventListener("click", () => setPreviewsLayout("list")); + if (gb) gb.addEventListener("click", () => setPreviewsLayout("grid")); + let saved = "list"; + try { + saved = localStorage.getItem(LAYOUT_KEY) || "list"; + } catch { + /* storage unavailable */ + } + setPreviewsLayout(saved); +})(); + /* ---------------- Browse tab (live page + route mirroring) ---------------- A sandboxed iframe renders the live page through the same-origin /api/proxy (so any site embeds and its in-page navigation can flow back here). Editing @@ -1387,11 +1434,20 @@ $("#browse-open").addEventListener("click", () => { window.addEventListener("message", (e) => { const m = e && e.data; if (!m || m.source !== "og-browse" || m.type !== "nav" || !m.url) return; + if (!/^https?:\/\//i.test(m.url)) return; // ignore non-http targets (e.g. about:srcdoc) browseInput.value = m.url; if (canonUrl(m.url) === browseFrameUrl) return; // echo from our own render browseFrameUrl = canonUrl(m.url); // genuine in-frame route change input.value = m.url; // bind the top-level URL to the browsed route - navBrowseFrame(m.url); // advance the embedded frame to the clicked page + if (m.mode === "soft") { + // SPA history change (pushState/replaceState): the frame already updated + // its own DOM in place. Re-rendering it via /api/proxy would wipe that + // client-side state (e.g. aspire.dev's selected language tab snapping back + // to the default), so only refresh the previews — never reload the frame. + scheduleBrowsePreview(m.url); + return; + } + navBrowseFrame(m.url); // hard nav (link click) / initial: advance the frame scheduleBrowsePreview(m.url); // refresh the previews (skipBrowse) }); diff --git a/.github/extensions/og-preview/ui/index.html b/.github/extensions/og-preview/ui/index.html index 8ea303190..82112f45d 100644 --- a/.github/extensions/og-preview/ui/index.html +++ b/.github/extensions/og-preview/ui/index.html @@ -54,7 +54,13 @@ <main class="content"> <section id="panel-previews" class="panel"> - <div id="previews" class="previews-grid"></div> + <div class="previews-toolbar"> + <div class="layout-toggle" role="group" aria-label="Preview layout"> + <button id="layout-list" class="layout-btn active" type="button" title="List view (larger)" aria-label="List view" aria-pressed="true"></button> + <button id="layout-grid" class="layout-btn" type="button" title="Grid view (compact)" aria-label="Grid view" aria-pressed="false"></button> + </div> + </div> + <div id="previews" class="previews-grid layout-list"></div> </section> <section id="panel-raw" class="panel"> <div class="raw-actions"> @@ -78,7 +84,7 @@ </button> <form id="browse-form" class="browse-form" autocomplete="off"> <div class="browse-input-wrap"> - <svg class="browse-input-icon" width="13" height="13" viewBox="0 0 16 16" aria-hidden="true"> + <svg class="browse-input-icon" width="15" height="15" viewBox="0 0 16 16" aria-hidden="true"> <path fill="currentColor" d="M7.775 3.275a.75.75 0 0 0 1.06 1.06l1.25-1.25a2 2 0 1 1 2.83 2.83l-2.5 2.5a2 2 0 0 1-2.83 0 .75.75 0 0 0-1.06 1.06 3.5 3.5 0 0 0 4.95 0l2.5-2.5a3.5 3.5 0 0 0-4.95-4.95l-1.25 1.25Zm-1.55 9.45a.75.75 0 0 0-1.06-1.06l-1.25 1.25a2 2 0 1 1-2.83-2.83l2.5-2.5a2 2 0 0 1 2.83 0 .75.75 0 0 0 1.06-1.06 3.5 3.5 0 0 0-4.95 0l-2.5 2.5a3.5 3.5 0 0 0 4.95 4.95l1.25-1.25Z" @@ -112,7 +118,7 @@ id="browse-frame" class="browse-frame" title="Live page preview" - sandbox="allow-scripts allow-forms allow-popups allow-popups-to-escape-sandbox" + sandbox="allow-scripts allow-forms" referrerpolicy="no-referrer" ></iframe> <div id="browse-empty" class="browse-empty"> diff --git a/.github/extensions/og-preview/ui/styles.css b/.github/extensions/og-preview/ui/styles.css index 3ce29e055..de90be3be 100644 --- a/.github/extensions/og-preview/ui/styles.css +++ b/.github/extensions/og-preview/ui/styles.css @@ -470,24 +470,30 @@ body.browse-active .empty { } .browse-input-icon { position: absolute; - left: 9px; + left: 10px; color: var(--text-color-muted, #59636e); pointer-events: none; } .browse-input { width: 100%; - font-family: var(--font-mono, "SFMono-Regular", Consolas, monospace); - font-size: 12.5px; - color: var(--text-color-default, #1f2328); - background: var(--surface-inset); - border: 1px solid var(--border-soft); + height: 32px; + padding: 0 12px 0 32px; border-radius: var(--radius); - padding: 6px 10px 6px 28px; + border: 1px solid var(--border-color-default, #d1d9e0); + background: var(--surface-inset); + color: inherit; + font-size: 13px; + font-family: var(--font-mono, monospace); outline: none; } +.browse-input::placeholder { + color: var(--text-color-muted, #59636e); + opacity: 0.7; +} .browse-input:focus { border-color: var(--accent); box-shadow: 0 0 0 3px var(--accent-soft); + background: var(--background-color-default, #ffffff); } .browse-stage { position: relative; @@ -528,12 +534,65 @@ body.browse-active .empty { } /* ---------- Previews grid ---------- */ +/* A toolbar above the grid switches between a roomy single-column List view + (default, larger cards) and a compact multi-column Grid view (small cards). */ +.previews-toolbar { + display: flex; + justify-content: flex-end; + align-items: center; + margin-bottom: 14px; +} +.layout-toggle { + display: inline-flex; + border: 1px solid var(--border-color-default, #d1d9e0); + border-radius: var(--radius); + overflow: hidden; + background: var(--surface-inset); +} +.layout-btn { + display: inline-flex; + align-items: center; + justify-content: center; + width: 32px; + height: 28px; + padding: 0; + border: 0; + background: transparent; + color: var(--text-color-muted, #59636e); + cursor: pointer; + transition: + background 0.14s ease, + color 0.14s ease; +} +.layout-btn + .layout-btn { + border-left: 1px solid var(--border-color-default, #d1d9e0); +} +.layout-btn:hover { + color: var(--text-color-default, #1f2328); +} +.layout-btn.active { + background: var(--accent-soft); + color: var(--accent); +} +.layout-btn:focus-visible { + outline: 2px solid var(--accent); + outline-offset: -2px; +} + /* Multi-column (masonry-style) packing so short cards don't leave the vertical gaps a rigid grid row creates when a tall card sets the row height. */ .previews-grid { - column-width: 360px; column-gap: 20px; } +.previews-grid.layout-grid { + column-width: 300px; + column-gap: 16px; +} +.previews-grid.layout-list { + column-count: 1; + column-width: auto; + max-width: 560px; +} .preview { min-width: 0; From 68acb11b7d5e9cefc57598c431d545f3ddc3dd80 Mon Sep 17 00:00:00 2001 From: David Pine <7679720+IEvangelist@users.noreply.github.com> Date: Tue, 30 Jun 2026 10:00:09 -0500 Subject: [PATCH 07/21] Add Reddit and Mastodon preview cards, fix grid icon, add previews header - Replace malformed grid toggle icon with a clean 2x2 grid octicon - Add a "Link previews" header to the previews toolbar, left of the layout toggle - Add Reddit (compact link card) and Mastodon (vertical status card) previews with brand icons, colors, and light/dark styles Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .github/extensions/og-preview/ui/app.js | 40 ++++++- .github/extensions/og-preview/ui/index.html | 1 + .github/extensions/og-preview/ui/styles.css | 126 +++++++++++++++++++- 3 files changed, 163 insertions(+), 4 deletions(-) diff --git a/.github/extensions/og-preview/ui/app.js b/.github/extensions/og-preview/ui/app.js index 5618ecf57..ea4d0a708 100644 --- a/.github/extensions/og-preview/ui/app.js +++ b/.github/extensions/og-preview/ui/app.js @@ -53,8 +53,8 @@ const OCTICONS = { '<path fill="currentColor" d="M3.75 2A1.75 1.75 0 0 0 2 3.75v8.5C2 13.22 2.78 14 3.75 14h8.5A1.75 1.75 0 0 0 14 12.25v-3a.75.75 0 0 0-1.5 0v3a.25.25 0 0 1-.25.25h-8.5a.25.25 0 0 1-.25-.25v-8.5a.25.25 0 0 1 .25-.25h3a.75.75 0 0 0 0-1.5h-3Z"/><path fill="currentColor" d="M8.5 1.75A.75.75 0 0 1 9.25 1h5a.75.75 0 0 1 .75.75v5a.75.75 0 0 1-1.5 0V3.56L8.78 8.28a.75.75 0 1 1-1.06-1.06l4.72-4.72H9.25a.75.75 0 0 1-.75-.75Z"/>', "list-unordered": '<path fill="currentColor" d="M5.75 2.5h8.5a.75.75 0 0 1 0 1.5h-8.5a.75.75 0 0 1 0-1.5Zm0 5h8.5a.75.75 0 0 1 0 1.5h-8.5a.75.75 0 0 1 0-1.5Zm0 5h8.5a.75.75 0 0 1 0 1.5h-8.5a.75.75 0 0 1 0-1.5ZM2 14a1 1 0 1 1 0-2 1 1 0 0 1 0 2Zm1-6a1 1 0 1 1-2 0 1 1 0 0 1 2 0ZM2 4a1 1 0 1 1 0-2 1 1 0 0 1 0 2Z"/>', - apps: - '<path fill="currentColor" d="M2.75 2.5a.25.25 0 0 0-.25.25v1.5c0 .138.112.25.25.25h1.5a.25.25 0 0 0 .25-.25v-1.5a.25.25 0 0 0-.25-.25Zm-1.75.25C1 1.784 1.784 1 2.75 1h1.5c.966 0 1.75.784 1.75 1.75v1.5A1.75 1.75 0 0 1 4.25 6h-1.5A1.75 1.75 0 0 1 1 4.25Zm9-.25a.25.25 0 0 0-.25.25v1.5c0 .138.112.25.25.25h1.5a.25.25 0 0 0 .25-.25v-1.5a.25.25 0 0 0-.25-.25ZM10 2.75C10 1.784 10.784 1 11.75 1h1.5C14.216 1 15 1.784 15 2.75v1.5A1.75 1.75 0 0 1 13.25 6h-1.5A1.75 1.75 0 0 1 10 4.25Zm-7.25 7.75a.25.25 0 0 0-.25.25v1.5c0 .138.112.25.25.25h1.5a.25.25 0 0 0 .25-.25v-1.5a.25.25 0 0 0-.25-.25ZM1 11.75C1 10.784 1.784 10 2.75 10h1.5c.966 0 1.75.784 1.75 1.75v1.5A1.75 1.75 0 0 1 4.25 15h-1.5A1.75 1.75 0 0 1 1 13.25Zm10.75-1.75a.25.25 0 0 0-.25.25v1.5c0 .138.112.25.25.25h1.5a.25.25 0 0 0 .25-.25v-1.5a.25.25 0 0 0-.25-.25ZM10 11.75c0-.966.784-1.75 1.75-1.75h1.5c.966 0 1.75.784 1.75 1.75v1.5A1.75 1.75 0 0 1 13.25 15h-1.5A1.75 1.75 0 0 1 10 13.25Z"/>', + grid: + '<rect x="1.75" y="1.75" width="5.25" height="5.25" rx="1.4" fill="currentColor"/><rect x="9" y="1.75" width="5.25" height="5.25" rx="1.4" fill="currentColor"/><rect x="1.75" y="9" width="5.25" height="5.25" rx="1.4" fill="currentColor"/><rect x="9" y="9" width="5.25" height="5.25" rx="1.4" fill="currentColor"/>', }; function octicon(name, size, cls) { @@ -81,6 +81,10 @@ const BRAND_ICONS = { "M5.202 2.857C7.954 4.922 10.913 9.11 12 11.358c1.087-2.247 4.046-6.436 6.798-8.501C20.783 1.366 24 .213 24 3.883c0 .732-.42 6.156-.667 7.037-.856 3.061-3.978 3.842-6.755 3.37 4.854.826 6.089 3.562 3.422 6.299-5.065 5.196-7.28-1.304-7.847-2.97-.104-.305-.152-.448-.153-.327 0-.121-.05.022-.153.327-.568 1.666-2.782 8.166-7.847 2.97-2.667-2.737-1.432-5.473 3.422-6.3-2.777.473-5.899-.308-6.755-3.369C.42 10.04 0 4.615 0 3.883c0-3.67 3.217-2.517 5.202-1.026", teams: "M20.625 8.127q-.55 0-1.025-.205-.475-.205-.832-.563-.358-.357-.563-.832Q18 6.053 18 5.502q0-.54.205-1.02t.563-.837q.357-.358.832-.563.474-.205 1.025-.205.54 0 1.02.205t.837.563q.358.357.563.837.205.48.205 1.02 0 .55-.205 1.025-.205.475-.563.832-.357.358-.837.563-.48.205-1.02.205zm0-3.75q-.469 0-.797.328-.328.328-.328.797 0 .469.328.797.328.328.797.328.469 0 .797-.328.328-.328.328-.797 0-.469-.328-.797-.328-.328-.797-.328zM24 10.002v5.578q0 .774-.293 1.46-.293.685-.803 1.194-.51.51-1.195.803-.686.293-1.459.293-.445 0-.908-.105-.463-.106-.85-.329-.293.95-.855 1.729-.563.78-1.319 1.336-.756.557-1.67.861-.914.305-1.898.305-1.148 0-2.162-.398-1.014-.399-1.805-1.102-.79-.703-1.312-1.664t-.674-2.086h-5.8q-.411 0-.704-.293T0 16.881V6.873q0-.41.293-.703t.703-.293h8.59q-.34-.715-.34-1.5 0-.727.275-1.365.276-.639.75-1.114.475-.474 1.114-.75.638-.275 1.365-.275t1.365.275q.639.276 1.114.75.474.475.75 1.114.275.638.275 1.365t-.275 1.365q-.276.639-.75 1.113-.475.475-1.114.75-.638.276-1.365.276-.188 0-.375-.024-.188-.023-.375-.058v1.078h10.875q.469 0 .797.328.328.328.328.797zM12.75 2.373q-.41 0-.78.158-.368.158-.638.434-.27.275-.428.639-.158.363-.158.773 0 .41.158.78.159.368.428.638.27.27.639.428.369.158.779.158.41 0 .773-.158.364-.159.64-.428.274-.27.433-.639.158-.369.158-.779 0-.41-.158-.773-.159-.364-.434-.64-.275-.275-.639-.433-.363-.158-.773-.158zM6.937 9.814h2.25V7.94H2.814v1.875h2.25v6h1.875zm10.313 7.313v-6.75H12v6.504q0 .41-.293.703t-.703.293H8.309q.152.809.556 1.5.405.691.985 1.19.58.497 1.318.779.738.281 1.582.281.926 0 1.746-.352.82-.351 1.436-.966.615-.616.966-1.43.352-.815.352-1.752zm5.25-1.547v-5.203h-3.75v6.855q.305.305.691.452.387.146.809.146.469 0 .879-.176.41-.175.715-.48.304-.305.48-.715t.176-.879Z", + reddit: + "M12 0A12 12 0 0 0 0 12a12 12 0 0 0 12 12 12 12 0 0 0 12-12A12 12 0 0 0 12 0zm5.01 4.744c.688 0 1.25.561 1.25 1.249a1.25 1.25 0 0 1-2.498.056l-2.597-.547-.8 3.747c1.824.07 3.48.632 4.674 1.488.308-.309.73-.491 1.207-.491.968 0 1.754.786 1.754 1.754 0 .716-.435 1.333-1.01 1.614a3.111 3.111 0 0 1 .042.52c0 2.694-3.13 4.87-7.004 4.87-3.874 0-7.004-2.176-7.004-4.87 0-.183.015-.366.043-.534A1.748 1.748 0 0 1 4.028 12c0-.968.786-1.754 1.754-1.754.463 0 .898.196 1.207.49 1.207-.883 2.878-1.43 4.744-1.487l.885-4.182a.342.342 0 0 1 .14-.197.35.35 0 0 1 .238-.042l2.906.617a1.214 1.214 0 0 1 1.108-.701zM9.25 12C8.561 12 8 12.562 8 13.25c0 .687.561 1.248 1.25 1.248.687 0 1.248-.561 1.248-1.249 0-.688-.561-1.249-1.249-1.249zm5.5 0c-.687 0-1.248.561-1.248 1.25 0 .687.561 1.248 1.249 1.248.688 0 1.249-.561 1.249-1.249 0-.687-.562-1.249-1.25-1.249zm-5.466 3.99a.327.327 0 0 0-.231.094.33.33 0 0 0 0 .463c.842.842 2.484.913 2.961.913.477 0 2.105-.056 2.961-.913a.361.361 0 0 0 .029-.463.33.33 0 0 0-.464 0c-.547.533-1.684.73-2.512.73-.828 0-1.979-.196-2.512-.73a.326.326 0 0 0-.232-.095z", + mastodon: + "M23.268 5.313c-.35-2.578-2.617-4.61-5.304-5.004C17.51.242 15.792 0 11.813 0h-.03c-3.98 0-4.835.242-5.288.309C3.882.692 1.496 2.518.917 5.127.64 6.412.61 7.837.661 9.143c.073 1.874.087 3.745.257 5.61.118 1.24.323 2.47.616 3.68.55 2.237 2.777 4.098 4.96 4.857 2.336.792 4.849.923 7.256.38.265-.061.527-.132.786-.213.585-.184 1.27-.39 1.774-.753a.057.057 0 0 0 .023-.043v-1.809a.052.052 0 0 0-.02-.041.053.053 0 0 0-.046-.01 20.282 20.282 0 0 1-4.709.545c-2.73 0-3.463-1.284-3.674-1.818a5.593 5.593 0 0 1-.319-1.433.053.053 0 0 1 .066-.054c1.517.363 3.072.546 4.632.546.376 0 .75 0 1.125-.01 1.57-.044 3.224-.124 4.768-.422.038-.008.077-.015.11-.024 2.435-.464 4.753-1.92 4.989-5.604.008-.145.03-1.52.03-1.67.002-.512.167-3.63-.024-5.545zm-3.748 9.195h-2.561V8.29c0-1.309-.55-1.976-1.67-1.976-1.23 0-1.846.79-1.846 2.35v3.403h-2.546V8.663c0-1.56-.617-2.35-1.848-2.35-1.112 0-1.668.668-1.67 1.977v6.218H4.822V8.102c0-1.31.337-2.35 1.011-3.12.696-.77 1.608-1.164 2.74-1.164 1.311 0 2.302.5 2.962 1.498l.638 1.06.638-1.06c.66-.999 1.65-1.498 2.96-1.498 1.13 0 2.043.395 2.74 1.164.675.77 1.012 1.81 1.012 3.12z", }; function brandIcon(name) { @@ -648,6 +652,34 @@ function teamsCard(d, domain) { ]); } +// Reddit link post: a compact card with the title + domain on the left and a +// small square thumbnail of the og:image on the right (the classic feed look). +function redditCard(d, domain) { + return el("div", { class: "reddit" }, [ + el("div", { class: "meta" }, [ + el("div", { class: "title", text: d.title || "(no title)" }), + el("div", { class: "domain" }, [ + el("span", { text: domain }), + octicon("link-external", 12, "rd-ext"), + ]), + ]), + d.image ? el("div", { class: "reddit-thumb" }, [makeImage(d.image, "card-img")]) : null, + ]); +} + +// Mastodon status card: a vertical card (image on top, then provider/title/desc), +// mirroring Mastodon's .status-card rendering for a large preview image. +function mastodonCard(d, domain) { + return el("div", { class: "mastodon" }, [ + d.image ? makeImage(d.image, "card-img") : null, + el("div", { class: "meta" }, [ + el("div", { class: "provider", text: d.siteName || domain }), + el("div", { class: "title", text: d.title || "(no title)" }), + d.description ? el("div", { class: "desc", text: d.description }) : null, + ]), + ]); +} + function renderPreviews(data) { const d = data.resolved; const domain = prettyDomain(d.hostname); @@ -656,7 +688,9 @@ function renderPreviews(data) { labeledCard("facebook", "OpenGraph · Facebook", facebookCard(d, domain)), labeledCard("x", "X · Twitter", twitterCard(d, domain)), labeledCard("bluesky", "Bluesky", blueskyCard(d, domain)), + labeledCard("mastodon", "Mastodon", mastodonCard(d, domain)), labeledCard("linkedin", "LinkedIn", linkedinCard(d, domain)), + labeledCard("reddit", "Reddit", redditCard(d, domain)), labeledCard("slack", "Slack", slackCard(d, domain)), labeledCard("teams", "Microsoft Teams", teamsCard(d, domain)), labeledCard("discord", "Discord", discordCard(d, domain)), @@ -1318,7 +1352,7 @@ function setPreviewsLayout(mode) { const lb = $("#layout-list"); const gb = $("#layout-grid"); if (lb && !lb.querySelector(".octicon")) lb.appendChild(octicon("list-unordered", 16)); - if (gb && !gb.querySelector(".octicon")) gb.appendChild(octicon("apps", 16)); + if (gb && !gb.querySelector(".octicon")) gb.appendChild(octicon("grid", 16)); if (lb) lb.addEventListener("click", () => setPreviewsLayout("list")); if (gb) gb.addEventListener("click", () => setPreviewsLayout("grid")); let saved = "list"; diff --git a/.github/extensions/og-preview/ui/index.html b/.github/extensions/og-preview/ui/index.html index 82112f45d..a528d822e 100644 --- a/.github/extensions/og-preview/ui/index.html +++ b/.github/extensions/og-preview/ui/index.html @@ -55,6 +55,7 @@ <main class="content"> <section id="panel-previews" class="panel"> <div class="previews-toolbar"> + <h2 class="previews-title">Link previews</h2> <div class="layout-toggle" role="group" aria-label="Preview layout"> <button id="layout-list" class="layout-btn active" type="button" title="List view (larger)" aria-label="List view" aria-pressed="true"></button> <button id="layout-grid" class="layout-btn" type="button" title="Grid view (compact)" aria-label="Grid view" aria-pressed="false"></button> diff --git a/.github/extensions/og-preview/ui/styles.css b/.github/extensions/og-preview/ui/styles.css index de90be3be..bff9dc868 100644 --- a/.github/extensions/og-preview/ui/styles.css +++ b/.github/extensions/og-preview/ui/styles.css @@ -538,10 +538,18 @@ body.browse-active .empty { (default, larger cards) and a compact multi-column Grid view (small cards). */ .previews-toolbar { display: flex; - justify-content: flex-end; + justify-content: space-between; align-items: center; + gap: 12px; margin-bottom: 14px; } +.previews-title { + margin: 0; + font-size: 13px; + font-weight: var(--font-weight-semibold, 600); + color: var(--text-color-default, #1f2328); + letter-spacing: 0.01em; +} .layout-toggle { display: inline-flex; border: 1px solid var(--border-color-default, #d1d9e0); @@ -714,6 +722,12 @@ body.browse-active .empty { .brand-teams { color: #6264a7; } +.brand-reddit { + color: #ff4500; +} +.brand-mastodon { + color: #6364ff; +} /* Keep dark / aubergine logos legible on a dark canvas. */ body[data-mode="dark"] .brand-x { color: #e7e9ea; @@ -1001,6 +1015,85 @@ body[data-mode="dark"] .brand-teams { margin-top: 3px; } +/* Reddit (link post card) */ +.reddit { + display: flex; + gap: 12px; + align-items: flex-start; + border: 1px solid #ccc; + border-radius: 16px; + overflow: hidden; + background: #fff; + color: #1c1c1c; + padding: 12px; +} +.reddit .meta { + flex: 1 1 auto; + min-width: 0; +} +.reddit .title { + font-size: 15px; + font-weight: 600; + line-height: 1.3; + color: #1c1c1c; +} +.reddit .domain { + display: flex; + align-items: center; + gap: 4px; + font-size: 12px; + color: #576f76; + margin-top: 6px; +} +.reddit .domain .octicon { + color: #576f76; +} +.reddit-thumb { + flex: 0 0 auto; + width: 84px; + height: 84px; + border-radius: 8px; + overflow: hidden; + border: 1px solid #e2e2e2; +} +.reddit-thumb .card-img { + width: 100%; + height: 100%; + aspect-ratio: auto; + object-fit: cover; +} + +/* Mastodon (status preview card) */ +.mastodon { + border: 1px solid #c0cdd9; + border-radius: 8px; + overflow: hidden; + background: #fff; + color: #191b22; +} +.mastodon .card-img { + aspect-ratio: 1.91 / 1; +} +.mastodon .meta { + padding: 12px 14px; +} +.mastodon .provider { + font-size: 12px; + color: #606984; + margin-bottom: 4px; +} +.mastodon .title { + font-size: 15px; + font-weight: 700; + line-height: 1.3; + color: #191b22; +} +.mastodon .desc { + font-size: 14px; + color: #606984; + margin-top: 4px; +} + .card-missing { padding: 14px; font-size: 13px; @@ -1101,6 +1194,37 @@ body[data-mode="dark"] .teams .desc { color: #c8c6c4; } +body[data-mode="dark"] .reddit { + background: #181c1f; + color: #d7dadc; + border-color: #343536; +} +body[data-mode="dark"] .reddit .title { + color: #d7dadc; +} +body[data-mode="dark"] .reddit .domain, +body[data-mode="dark"] .reddit .domain .octicon { + color: #818384; +} +body[data-mode="dark"] .reddit-thumb { + border-color: #343536; +} + +body[data-mode="dark"] .mastodon { + background: #1f232b; + color: #ecf0f4; + border-color: #393f4f; +} +body[data-mode="dark"] .mastodon .provider { + color: #a3a6ff; +} +body[data-mode="dark"] .mastodon .title { + color: #ecf0f4; +} +body[data-mode="dark"] .mastodon .desc { + color: #9baec8; +} + /* ---------- Raw ---------- */ .raw-actions { display: flex; From 65193ae76a80c0f5e47d7694d3b20cddca7d4c00 Mon Sep 17 00:00:00 2001 From: David Pine <7679720+IEvangelist@users.noreply.github.com> Date: Tue, 30 Jun 2026 10:30:13 -0500 Subject: [PATCH 08/21] Address PR review + fix code-hovercard HTML bug + Browse/preview UX - http-fetch: reject (not hang) when the response exceeds the size cap; add an SSRF guard that denies private/link-local/unique-local/CGNAT and cloud-metadata (169.254.169.254) ranges by default while always allowing localhost/loopback (opt in to private targets via OG_ALLOW_PRIVATE_NETWORK). - parse-og: resolve a relative <link rel=canonical> href against the base URL. - ARIA tabs: real aria-selected/aria-controls + role=tabpanel/aria-labelledby linkage, roving tabindex, and Arrow/Home/End keyboard navigation. - /api/raw: return a clear error instead of dumping an HTML error/redirect page into the code hovercard (fixes e.g. a .mdx value resolving to a 404 HTML page); guards both HTTP >= 400 and HTML content on a non-html extension. - Browse tab: drop its separate URL bar and drive it from the main address bar; move an "open in browser" button into the main toolbar (shown only on Browse); Reload now force-refreshes the embedded frame on the Browse tab. - Previews: the List layout now flows into multiple columns on wide panels (column-width 480px) while staying single-column on narrow panels. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .github/extensions/og-preview/extension.mjs | 20 +++++ .../extensions/og-preview/lib/http-fetch.mjs | 89 ++++++++++++++++++- .../extensions/og-preview/lib/parse-og.mjs | 2 +- .github/extensions/og-preview/ui/app.js | 61 ++++++++----- .github/extensions/og-preview/ui/index.html | 75 +++++----------- .github/extensions/og-preview/ui/styles.css | 52 +++-------- 6 files changed, 180 insertions(+), 119 deletions(-) diff --git a/.github/extensions/og-preview/extension.mjs b/.github/extensions/og-preview/extension.mjs index 631dee9c6..77df57f3e 100644 --- a/.github/extensions/og-preview/extension.mjs +++ b/.github/extensions/og-preview/extension.mjs @@ -294,6 +294,26 @@ async function handleRequest(entry, req, res) { } const MAX_CHARS = 200000; let text = r.body.toString("utf8"); + // Surface upstream errors and HTML fallback/error pages instead of + // dumping a rendered web page into the code preview. A code/markdown + // value (e.g. a .mdx path) that resolves to an HTML document is almost + // always a 404/redirect or SPA shell, not the raw file. + if (r.status >= 400) { + return sendJson(res, 200, { + error: `The server returned HTTP ${r.status} for this file.`, + }); + } + const rawExt = ( + (r.url.split(/[?#]/)[0].match(/\.([a-z0-9]+)$/i) || [])[1] || "" + ).toLowerCase(); + const looksHtml = + /text\/html|application\/xhtml\+xml/i.test(ct) || + /^\s*(?:<!doctype\s+html|<html[\s>])/i.test(text.slice(0, 256)); + if (looksHtml && !["html", "htm", "xhtml"].includes(rawExt)) { + return sendJson(res, 200, { + error: "The server returned an HTML page, not the raw file (likely an error or redirect).", + }); + } // Reject content that is clearly binary (NUL byte in the sample). if (text.slice(0, 4096).includes("\u0000")) { return sendJson(res, 200, { error: "File appears to be binary." }); diff --git a/.github/extensions/og-preview/lib/http-fetch.mjs b/.github/extensions/og-preview/lib/http-fetch.mjs index fdc357e58..d24a563a1 100644 --- a/.github/extensions/og-preview/lib/http-fetch.mjs +++ b/.github/extensions/og-preview/lib/http-fetch.mjs @@ -4,6 +4,8 @@ import http from "node:http"; import https from "node:https"; +import dns from "node:dns/promises"; +import net from "node:net"; const USER_AGENT = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 " + @@ -11,6 +13,76 @@ const USER_AGENT = const LOCAL_HOST_RE = /^(localhost|127\.0\.0\.1|0\.0\.0\.0|\[::1\]|::1|.*\.localhost)(:|\/|$)/i; +// SSRF guard. The tool intentionally supports localhost, but every other +// private / link-local / unique-local / carrier-grade-NAT range is denied by +// default so the agent-callable actions and the loopback proxy can't be turned +// into a request-forgery primitive against the developer's machine/network +// (e.g. the 169.254.169.254 cloud metadata endpoint). Set +// OG_ALLOW_PRIVATE_NETWORK=1 to opt in to private destinations beyond localhost. +const ALLOW_PRIVATE_NETWORK = /^(1|true|yes|on)$/i.test( + String(process.env.OG_ALLOW_PRIVATE_NETWORK || ""), +); + +function ipv4Allowed(ip, allowPrivate) { + const o = ip.split(".").map((n) => Number(n)); + if (o.length !== 4 || o.some((n) => !Number.isInteger(n) || n < 0 || n > 255)) { + return false; + } + const [a, b] = o; + if (a === 127) return true; // loopback (localhost) — always allowed + if (allowPrivate) return true; + if (a === 0) return false; // "this" network + if (a === 10) return false; // private + if (a === 172 && b >= 16 && b <= 31) return false; // private + if (a === 192 && b === 168) return false; // private + if (a === 169 && b === 254) return false; // link-local + cloud metadata + if (a === 100 && b >= 64 && b <= 127) return false; // carrier-grade NAT + return true; +} + +function ipv6Allowed(ip, allowPrivate) { + const s = ip.toLowerCase(); + const mapped = s.match(/^::ffff:(\d+\.\d+\.\d+\.\d+)$/); + if (mapped) return ipv4Allowed(mapped[1], allowPrivate); + if (s === "::1") return true; // loopback + if (allowPrivate) return true; + if (s === "::") return false; // unspecified + if (/^fe[89ab]/.test(s)) return false; // fe80::/10 link-local + if (/^f[cd]/.test(s)) return false; // fc00::/7 unique-local + return true; +} + +function addressAllowed(ip, allowPrivate) { + const v = net.isIP(ip); + if (v === 4) return ipv4Allowed(ip, allowPrivate); + if (v === 6) return ipv6Allowed(ip, allowPrivate); + return false; +} + +// Resolve a hostname to its addresses and confirm none land in a denied range. +// Literal IPs are checked directly; explicit localhost names are always allowed. +async function assertHostAllowed(hostname, allowPrivate) { + const host = hostname.startsWith("[") ? hostname.slice(1, -1) : hostname; + if (LOCAL_HOST_RE.test(host)) return; // localhost / *.localhost / 127.* / ::1 + if (net.isIP(host)) { + if (!addressAllowed(host, allowPrivate)) { + throw new Error(`Blocked non-public address: ${host}`); + } + return; + } + let addrs; + try { + addrs = await dns.lookup(host, { all: true }); + } catch { + throw new Error(`Could not resolve host: ${host}`); + } + for (const a of addrs) { + if (!addressAllowed(a.address, allowPrivate)) { + throw new Error(`Blocked non-public address for ${host}: ${a.address}`); + } + } +} + /** * Normalize user input into an absolute URL. Bare localhost-ish hosts default * to http://, everything else defaults to https://. @@ -35,12 +107,13 @@ export function fetchUrl(rawUrl, options = {}) { timeoutMs = 15000, accept = "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8", maxBytes = MAX_BYTES, + allowPrivateNetwork = ALLOW_PRIVATE_NETWORK, } = options; return new Promise((resolve, reject) => { let redirects = 0; - const visit = (urlStr) => { + const visit = async (urlStr) => { let parsed; try { parsed = new URL(urlStr); @@ -50,6 +123,11 @@ export function fetchUrl(rawUrl, options = {}) { if (parsed.protocol !== "http:" && parsed.protocol !== "https:") { return reject(new Error(`Unsupported protocol: ${parsed.protocol}`)); } + try { + await assertHostAllowed(parsed.hostname, allowPrivateNetwork); + } catch (err) { + return reject(err); + } const lib = parsed.protocol === "https:" ? https : http; const req = lib.request( @@ -78,7 +156,8 @@ export function fetchUrl(rawUrl, options = {}) { } catch { return reject(new Error(`Bad redirect target: ${location}`)); } - return visit(next); + visit(next).catch(reject); + return; } const chunks = []; @@ -90,7 +169,9 @@ export function fetchUrl(rawUrl, options = {}) { aborted = true; req.destroy(); res.destroy(); - return; + return reject( + new Error(`Response exceeded the ${maxBytes}-byte limit.`), + ); } chunks.push(chunk); }); @@ -117,6 +198,6 @@ export function fetchUrl(rawUrl, options = {}) { req.end(); }; - visit(rawUrl); + visit(rawUrl).catch(reject); }); } diff --git a/.github/extensions/og-preview/lib/parse-og.mjs b/.github/extensions/og-preview/lib/parse-og.mjs index 40be0a333..5e07dde37 100644 --- a/.github/extensions/og-preview/lib/parse-og.mjs +++ b/.github/extensions/og-preview/lib/parse-og.mjs @@ -82,7 +82,7 @@ export function parseMetadata(html, baseUrl) { const a = parseAttrs(tag); const rel = (a.rel || "").toLowerCase(); if (!rel || !a.href) continue; - if (rel.includes("canonical")) canonical = a.href; + if (rel.includes("canonical")) canonical = resolveUrl(a.href, baseUrl); if (rel.includes("icon")) { icons.push({ rel, href: resolveUrl(a.href, baseUrl), sizes: a.sizes || "" }); } diff --git a/.github/extensions/og-preview/ui/app.js b/.github/extensions/og-preview/ui/app.js index ea4d0a708..75efd97d7 100644 --- a/.github/extensions/og-preview/ui/app.js +++ b/.github/extensions/og-preview/ui/app.js @@ -1272,7 +1272,17 @@ $("#url-form").addEventListener("submit", (e) => { e.preventDefault(); load(input.value); }); -$("#refresh").addEventListener("click", () => load(input.value)); +$("#refresh").addEventListener("click", () => { + if (browseActive()) { + // Force the embedded frame to re-fetch (syncBrowseFrame would skip an + // unchanged URL), then refresh the previews without re-driving the frame. + const u = withScheme(input.value || pendingBrowseUrl); + if (u) navBrowseFrame(u); + load(input.value, { skipBrowse: true }); + } else { + load(input.value); + } +}); // Keep a scheme prefilled so the user never has to type it. input.addEventListener("focus", () => { @@ -1297,9 +1307,13 @@ function activateTab(name) { const tab = document.querySelector(`.tab[data-tab="${name}"]`); const panel = $("#panel-" + name); if (!tab || !panel) return; - document.querySelectorAll(".tab").forEach((t) => t.classList.remove("active")); + document.querySelectorAll(".tab").forEach((t) => { + const on = t === tab; + t.classList.toggle("active", on); + t.setAttribute("aria-selected", on ? "true" : "false"); + t.tabIndex = on ? 0 : -1; + }); document.querySelectorAll(".panel").forEach((p) => p.classList.remove("active")); - tab.classList.add("active"); panel.classList.add("active"); document.body.classList.toggle("browse-active", name === "browse"); try { @@ -1310,6 +1324,8 @@ function activateTab(name) { if (name === "browse") syncBrowseFrame(pendingBrowseUrl || input.value); } +const TAB_ORDER = ["browse", "previews", "raw", "diagnostics"]; + document.querySelectorAll(".tab").forEach((tab) => { const iconName = TAB_ICONS[tab.dataset.tab]; if (iconName && !tab.querySelector(".octicon")) { @@ -1319,6 +1335,26 @@ document.querySelectorAll(".tab").forEach((tab) => { if (tab.classList.contains("active")) return; withTransition(() => activateTab(tab.dataset.tab)); }); + tab.addEventListener("keydown", (e) => { + const i = TAB_ORDER.indexOf(tab.dataset.tab); + if (i < 0) return; + let next = null; + if (e.key === "ArrowRight" || e.key === "ArrowDown") { + next = TAB_ORDER[(i + 1) % TAB_ORDER.length]; + } else if (e.key === "ArrowLeft" || e.key === "ArrowUp") { + next = TAB_ORDER[(i - 1 + TAB_ORDER.length) % TAB_ORDER.length]; + } else if (e.key === "Home") { + next = TAB_ORDER[0]; + } else if (e.key === "End") { + next = TAB_ORDER[TAB_ORDER.length - 1]; + } else { + return; + } + e.preventDefault(); + const nextTab = document.querySelector(`.tab[data-tab="${next}"]`); + if (nextTab) nextTab.focus(); + withTransition(() => activateTab(next)); + }); }); /* ---------------- Previews layout toggle (List default / Grid compact) ---- @@ -1371,7 +1407,6 @@ function setPreviewsLayout(mode) { mirrored into the frame. */ const browseFrame = $("#browse-frame"); -const browseInput = $("#browse-input"); const browsePanel = $("#panel-browse"); let browseFrameUrl = ""; // canonical URL the frame currently points at let pendingBrowseUrl = ""; // latest loaded URL the frame should show @@ -1401,7 +1436,6 @@ async function navBrowseFrame(rawUrl) { const u = withScheme(rawUrl); if (!u) return; browseFrameUrl = canonUrl(u); - browseInput.value = u; browsePanel.classList.add("has-browse"); const token = ++browseNavToken; try { @@ -1437,22 +1471,8 @@ function scheduleBrowsePreview(url) { }, 300); } -$("#browse-form").addEventListener("submit", (e) => { - e.preventDefault(); - const u = browseInput.value; - if (!u || !u.trim()) return; - input.value = withScheme(u); // keep the top-level URL bound to the route bar - navBrowseFrame(u); - load(u, { silent: true, skipBrowse: true }); -}); - -$("#browse-reload").addEventListener("click", () => { - const u = browseInput.value || pendingBrowseUrl; - if (u && u.trim()) navBrowseFrame(u); -}); - $("#browse-open").addEventListener("click", () => { - const u = withScheme(browseInput.value || pendingBrowseUrl); + const u = withScheme(input.value || pendingBrowseUrl); if (!u) return; try { window.open(u, "_blank", "noopener"); @@ -1469,7 +1489,6 @@ window.addEventListener("message", (e) => { const m = e && e.data; if (!m || m.source !== "og-browse" || m.type !== "nav" || !m.url) return; if (!/^https?:\/\//i.test(m.url)) return; // ignore non-http targets (e.g. about:srcdoc) - browseInput.value = m.url; if (canonUrl(m.url) === browseFrameUrl) return; // echo from our own render browseFrameUrl = canonUrl(m.url); // genuine in-frame route change input.value = m.url; // bind the top-level URL to the browsed route diff --git a/.github/extensions/og-preview/ui/index.html b/.github/extensions/og-preview/ui/index.html index a528d822e..9022474b4 100644 --- a/.github/extensions/og-preview/ui/index.html +++ b/.github/extensions/og-preview/ui/index.html @@ -35,13 +35,25 @@ /> </svg> </button> + <button id="browse-open" class="btn btn-ghost btn-icon" type="button" title="Open page in browser" aria-label="Open page in browser"> + <svg width="15" height="15" viewBox="0 0 16 16" aria-hidden="true"> + <path + fill="currentColor" + d="M3.75 2A1.75 1.75 0 0 0 2 3.75v8.5C2 13.216 2.784 14 3.75 14h8.5A1.75 1.75 0 0 0 14 12.25v-3.5a.75.75 0 0 0-1.5 0v3.5a.25.25 0 0 1-.25.25h-8.5a.25.25 0 0 1-.25-.25v-8.5a.25.25 0 0 1 .25-.25h3.5a.75.75 0 0 0 0-1.5h-3.5Z" + /> + <path + fill="currentColor" + d="M9.75 2a.75.75 0 0 0 0 1.5h1.69L7.22 7.72a.75.75 0 1 0 1.06 1.06l4.22-4.22v1.69a.75.75 0 0 0 1.5 0v-3.5a.75.75 0 0 0-.75-.75h-3.5Z" + /> + </svg> + </button> </form> <div class="toolbar-row"> - <nav class="tabs" role="tablist"> - <button class="tab active" data-tab="browse" role="tab">Browse</button> - <button class="tab" data-tab="previews" role="tab">Previews</button> - <button class="tab" data-tab="raw" role="tab">Raw metadata</button> - <button class="tab" data-tab="diagnostics" role="tab">Diagnostics</button> + <nav class="tabs" role="tablist" aria-label="Preview views"> + <button class="tab active" data-tab="browse" id="tab-browse" role="tab" aria-selected="true" aria-controls="panel-browse" tabindex="0">Browse</button> + <button class="tab" data-tab="previews" id="tab-previews" role="tab" aria-selected="false" aria-controls="panel-previews" tabindex="-1">Previews</button> + <button class="tab" data-tab="raw" id="tab-raw" role="tab" aria-selected="false" aria-controls="panel-raw" tabindex="-1">Raw metadata</button> + <button class="tab" data-tab="diagnostics" id="tab-diagnostics" role="tab" aria-selected="false" aria-controls="panel-diagnostics" tabindex="-1">Diagnostics</button> </nav> <div class="examples"> <span class="examples-label muted">Try</span> @@ -53,7 +65,7 @@ <div id="status" class="status" hidden></div> <main class="content"> - <section id="panel-previews" class="panel"> + <section id="panel-previews" class="panel" role="tabpanel" aria-labelledby="tab-previews" tabindex="0"> <div class="previews-toolbar"> <h2 class="previews-title">Link previews</h2> <div class="layout-toggle" role="group" aria-label="Preview layout"> @@ -63,57 +75,17 @@ <h2 class="previews-title">Link previews</h2> </div> <div id="previews" class="previews-grid layout-list"></div> </section> - <section id="panel-raw" class="panel"> + <section id="panel-raw" class="panel" role="tabpanel" aria-labelledby="tab-raw" tabindex="0"> <div class="raw-actions"> <span id="raw-summary" class="muted"></span> <button id="copy-json" class="btn btn-ghost btn-sm" type="button">Copy JSON</button> </div> <div id="raw"></div> </section> - <section id="panel-diagnostics" class="panel"> + <section id="panel-diagnostics" class="panel" role="tabpanel" aria-labelledby="tab-diagnostics" tabindex="0"> <div id="diagnostics"></div> </section> - <section id="panel-browse" class="panel browse-panel active"> - <div class="browse-bar"> - <button id="browse-reload" class="btn btn-ghost btn-icon" type="button" title="Reload page" aria-label="Reload page"> - <svg width="15" height="15" viewBox="0 0 16 16" aria-hidden="true"> - <path - fill="currentColor" - d="M8 2.5a5.5 5.5 0 1 0 5.39 6.6.75.75 0 0 1 1.47.3A7 7 0 1 1 8 1c1.74 0 3.32.64 4.53 1.69V1.75a.75.75 0 0 1 1.5 0v3a.75.75 0 0 1-.75.75h-3a.75.75 0 0 1 0-1.5h1.36A5.48 5.48 0 0 0 8 2.5Z" - /> - </svg> - </button> - <form id="browse-form" class="browse-form" autocomplete="off"> - <div class="browse-input-wrap"> - <svg class="browse-input-icon" width="15" height="15" viewBox="0 0 16 16" aria-hidden="true"> - <path - fill="currentColor" - d="M7.775 3.275a.75.75 0 0 0 1.06 1.06l1.25-1.25a2 2 0 1 1 2.83 2.83l-2.5 2.5a2 2 0 0 1-2.83 0 .75.75 0 0 0-1.06 1.06 3.5 3.5 0 0 0 4.95 0l2.5-2.5a3.5 3.5 0 0 0-4.95-4.95l-1.25 1.25Zm-1.55 9.45a.75.75 0 0 0-1.06-1.06l-1.25 1.25a2 2 0 1 1-2.83-2.83l2.5-2.5a2 2 0 0 1 2.83 0 .75.75 0 0 0 1.06-1.06 3.5 3.5 0 0 0-4.95 0l-2.5 2.5a3.5 3.5 0 0 0 4.95 4.95l1.25-1.25Z" - /> - </svg> - <input - id="browse-input" - class="browse-input" - type="text" - spellcheck="false" - placeholder="Edit the route, or click links in the page below…" - aria-label="Browse route" - /> - </div> - </form> - <button id="browse-open" class="btn btn-ghost btn-icon" type="button" title="Open in browser" aria-label="Open in browser"> - <svg width="15" height="15" viewBox="0 0 16 16" aria-hidden="true"> - <path - fill="currentColor" - d="M3.75 2A1.75 1.75 0 0 0 2 3.75v8.5C2 13.216 2.784 14 3.75 14h8.5A1.75 1.75 0 0 0 14 12.25v-3.5a.75.75 0 0 0-1.5 0v3.5a.25.25 0 0 1-.25.25h-8.5a.25.25 0 0 1-.25-.25v-8.5a.25.25 0 0 1 .25-.25h3.5a.75.75 0 0 0 0-1.5h-3.5Z" - /> - <path - fill="currentColor" - d="M9.75 2a.75.75 0 0 0 0 1.5h1.69L7.22 7.72a.75.75 0 1 0 1.06 1.06l4.22-4.22v1.69a.75.75 0 0 0 1.5 0v-3.5a.75.75 0 0 0-.75-.75h-3.5Z" - /> - </svg> - </button> - </div> + <section id="panel-browse" class="panel browse-panel active" role="tabpanel" aria-labelledby="tab-browse" tabindex="0"> <div class="browse-stage"> <iframe id="browse-frame" @@ -124,8 +96,9 @@ <h2 class="previews-title">Link previews</h2> ></iframe> <div id="browse-empty" class="browse-empty"> <p class="muted"> - Load a URL to browse the live page here. Click links or edit the - route above and the previews update automatically. + Load a URL to browse the live page here. Click links in the + page or edit the address bar above and the previews update + automatically. </p> </div> </div> diff --git a/.github/extensions/og-preview/ui/styles.css b/.github/extensions/og-preview/ui/styles.css index bff9dc868..070c49940 100644 --- a/.github/extensions/og-preview/ui/styles.css +++ b/.github/extensions/og-preview/ui/styles.css @@ -454,46 +454,12 @@ body.browse-active .empty { flex-direction: column; gap: 10px; } -.browse-bar { - display: flex; - align-items: center; - gap: 8px; -} -.browse-form { - flex: 1; - min-width: 0; -} -.browse-input-wrap { - position: relative; - display: flex; - align-items: center; -} -.browse-input-icon { - position: absolute; - left: 10px; - color: var(--text-color-muted, #59636e); - pointer-events: none; -} -.browse-input { - width: 100%; - height: 32px; - padding: 0 12px 0 32px; - border-radius: var(--radius); - border: 1px solid var(--border-color-default, #d1d9e0); - background: var(--surface-inset); - color: inherit; - font-size: 13px; - font-family: var(--font-mono, monospace); - outline: none; -} -.browse-input::placeholder { - color: var(--text-color-muted, #59636e); - opacity: 0.7; +/* The "open in browser" toolbar button is only meaningful on the Browse tab. */ +#browse-open { + display: none; } -.browse-input:focus { - border-color: var(--accent); - box-shadow: 0 0 0 3px var(--accent-soft); - background: var(--background-color-default, #ffffff); +body.browse-active #browse-open { + display: inline-flex; } .browse-stage { position: relative; @@ -597,9 +563,11 @@ body.browse-active .empty { column-gap: 16px; } .previews-grid.layout-list { - column-count: 1; - column-width: auto; - max-width: 560px; + /* Larger, readable cards that still flow into multiple columns when the + panel is wide enough (single column on narrow panels). */ + column-width: 480px; + column-gap: 20px; + max-width: none; } .preview { From e52d9577b81cd99f8b86b89a10d6ebba0c2734a9 Mon Sep 17 00:00:00 2001 From: David Pine <7679720+IEvangelist@users.noreply.github.com> Date: Tue, 30 Jun 2026 11:12:32 -0500 Subject: [PATCH 09/21] Replace browse loading card with a slim top progress bar MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Swap the jarring "Fetching…" loading banner (which inserted between the header and content and shifted layout) for a thin trickle bar pinned to the bottom edge of the sticky toolbar. It fades in/out with body.is-busy and the fill width animates from JS (10% → cap 90% while loading, snap to 100% on completion). Removes the old spinner keyframes; #status now shows errors only. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .github/extensions/og-preview/ui/app.js | 48 ++++++++++++++++++++- .github/extensions/og-preview/ui/index.html | 3 ++ .github/extensions/og-preview/ui/styles.css | 39 +++++++++++------ 3 files changed, 75 insertions(+), 15 deletions(-) diff --git a/.github/extensions/og-preview/ui/app.js b/.github/extensions/og-preview/ui/app.js index 75efd97d7..1113552e2 100644 --- a/.github/extensions/og-preview/ui/app.js +++ b/.github/extensions/og-preview/ui/app.js @@ -1221,6 +1221,51 @@ function setStatus(kind, message) { statusEl.textContent = message; } +/* Slim top trickle bar (replaces the old "Fetching…" status card). Real + byte-progress isn't meaningful here — the server fetches the page and returns + a small JSON blob — so we ease toward a ~90% cap while waiting and snap to + 100% on completion. Visibility (fade in/out) is driven by body.is-busy in CSS; + this only animates the fill width. */ +const progressBar = $("#progress-bar"); +let progressTimer = null; +let progressResetTimer = null; +let progressValue = 0; + +function setProgressWidth(pct) { + progressValue = pct; + progressBar.style.width = pct + "%"; +} + +function resetProgressWidth() { + // Snap to 0 without animating, so the next load fills from the left edge. + progressBar.style.transition = "none"; + setProgressWidth(0); + void progressBar.offsetWidth; // force reflow before re-enabling transition + progressBar.style.transition = ""; +} + +function startProgress() { + clearInterval(progressTimer); + clearTimeout(progressResetTimer); + resetProgressWidth(); + setProgressWidth(10); + progressTimer = setInterval(() => { + const remaining = 90 - progressValue; + if (remaining <= 0.5) return; + // Decelerating trickle: bigger steps early, smaller near the cap. + setProgressWidth(progressValue + Math.max(0.5, remaining * 0.12)); + }, 220); +} + +function finishProgress() { + clearInterval(progressTimer); + clearTimeout(progressResetTimer); + setProgressWidth(100); + // After the fill completes and the container fades (body.is-busy removed), + // snap back to 0 so a subsequent load starts clean. + progressResetTimer = setTimeout(resetProgressWidth, 450); +} + async function load(rawUrl, opts) { const url = withScheme(rawUrl); if (!url) return; @@ -1229,7 +1274,7 @@ async function load(rawUrl, opts) { hideCodeCard(); input.value = url; document.body.classList.add("has-data", "is-busy"); - setStatus("loading", `Fetching ${url} …`); + startProgress(); $("#footer-summary").textContent = `Loading ${url} …`; // Show realistic shaped skeletons immediately so the layout is stable. renderSkeleton(); @@ -1262,6 +1307,7 @@ async function load(rawUrl, opts) { setStatus("error", `Couldn't load metadata: ${err.message}`); $("#footer-summary").textContent = `Failed to load ${url}`; } finally { + finishProgress(); document.body.classList.remove("is-busy"); } } diff --git a/.github/extensions/og-preview/ui/index.html b/.github/extensions/og-preview/ui/index.html index 9022474b4..58c38a527 100644 --- a/.github/extensions/og-preview/ui/index.html +++ b/.github/extensions/og-preview/ui/index.html @@ -60,6 +60,9 @@ <button class="chip" data-example="https://aspire.dev" type="button">aspire.dev</button> </div> </div> + <div id="progress" class="progress" aria-hidden="true"> + <div id="progress-bar" class="progress-bar"></div> + </div> </header> <div id="status" class="status" hidden></div> diff --git a/.github/extensions/og-preview/ui/styles.css b/.github/extensions/og-preview/ui/styles.css index 070c49940..4c3aded50 100644 --- a/.github/extensions/og-preview/ui/styles.css +++ b/.github/extensions/og-preview/ui/styles.css @@ -373,21 +373,32 @@ code { background: var(--surface-inset); } -.status.loading::before { - content: ""; - width: 13px; - height: 13px; - border-radius: 50%; - border: 2px solid var(--border-color-default, #d1d9e0); - border-top-color: var(--accent); - animation: spin 0.7s linear infinite; - flex: 0 0 auto; +/* ---------- Top load progress bar ---------- + Pinned to the bottom edge of the sticky toolbar (over its border), so it sits + directly below the tabs and above the page content without shifting layout. + Fades in/out with body.is-busy; the fill width is animated from JS. */ +.progress { + position: absolute; + left: 0; + right: 0; + bottom: -1px; + height: 2.5px; + pointer-events: none; + opacity: 0; + transition: opacity 0.25s ease 0.15s; + z-index: 6; } - -@keyframes spin { - to { - transform: rotate(360deg); - } +body.is-busy .progress { + opacity: 1; + transition: opacity 0.1s ease; +} +.progress-bar { + width: 0; + height: 100%; + border-radius: 0 2px 2px 0; + background: var(--accent); + box-shadow: 0 0 8px var(--accent-soft); + transition: width 0.2s ease; } .status.error { From a165c0b3f9e13e73a6d1b3c2a858f7a209fcc271 Mon Sep 17 00:00:00 2001 From: David Pine <7679720+IEvangelist@users.noreply.github.com> Date: Tue, 30 Jun 2026 11:12:40 -0500 Subject: [PATCH 10/21] Make the browse tab interactive via a script-rewriting proxy MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The browse iframe is sandboxed without allow-same-origin (opaque origin), so the framed page's ES-module scripts were blocked by CORS and its JS never ran (no theme toggle, sidebar, menus). Route the page's scripts, stylesheets and fonts through /api/proxy with Access-Control-Allow-Origin so the opaque-origin frame can load them — no allow-same-origin, so the page still can't reach the host canvas. Subresources use a path-style proxy URL (/api/proxy/<scheme>/<host>/<path>) so a bundle's relative imports resolve back through the proxy automatically. Rewrites JS root-relative/absolute import specifiers and CSS url()/@import targets; inline <style> and inline <script type=module> are rewritten too. Verified on aspire.dev: modules execute (mermaid renders), theme toggle works (light→dark), and link navigation still posts real URLs. Known limitation: Vite dynamic code-split chunks (root-relative at runtime) aren't proxied, so a few advanced diagram features stay inert. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .github/extensions/og-preview/extension.mjs | 196 +++++++++++++++++++- 1 file changed, 189 insertions(+), 7 deletions(-) diff --git a/.github/extensions/og-preview/extension.mjs b/.github/extensions/og-preview/extension.mjs index 77df57f3e..777760c8f 100644 --- a/.github/extensions/og-preview/extension.mjs +++ b/.github/extensions/og-preview/extension.mjs @@ -134,14 +134,140 @@ if(document.readyState==="loading")document.addEventListener("DOMContentLoaded", })();<\/script>`; } -// Rewrite a fetched HTML document so it renders inside the browse frame: drop -// the page's own <base> and any CSP <meta> (so our inline bridge isn't blocked), -// then inject a <base href> pointing at the real origin plus the bridge script. -function injectBrowseBridge(html, finalUrl, proxyBase) { +// --- Browse proxy URL rewriting -------------------------------------------- +// To make a framed page interactive, its scripts/styles must load from OUR +// loopback origin (same-origin-secure: we add Access-Control-Allow-Origin so the +// sandboxed opaque-origin frame can fetch them — no allow-same-origin needed, so +// the page still can't reach the host canvas). We use a PATH-style proxy URL, +// /api/proxy/<scheme>/<host>/<path>, instead of ?u=<encoded>, specifically so a +// bundled module's RELATIVE imports (./chunk.js) resolve against the proxied URL +// and transparently route back through us — no JS import rewriting required for +// the common case. + +/** Build a path-style proxy URL for an absolute http(s) URL (others pass through). */ +function proxyEncode(absUrl, appOrigin) { + let u; + try { + u = new URL(absUrl); + } catch { + return absUrl; + } + if (!/^https?:$/i.test(u.protocol)) return absUrl; // data:, mailto:, blob: … + const scheme = u.protocol.replace(/:$/, ""); + return `${appOrigin}/api/proxy/${scheme}/${u.host}${u.pathname}${u.search}${u.hash}`; +} + +/** Resolve a (possibly relative) spec against baseUrl and proxy it, or null. */ +function proxyResolve(spec, baseUrl, appOrigin) { + const s = String(spec || "").trim(); + if (!s || s.charAt(0) === "#") return null; + if (/^(data:|blob:|mailto:|tel:|javascript:|about:|#)/i.test(s)) return null; + let abs; + try { + abs = new URL(s, baseUrl).toString(); + } catch { + return null; + } + if (!/^https?:/i.test(abs)) return null; + return proxyEncode(abs, appOrigin); +} + +/** Decode a path-style proxy path back to the real target URL (or null). */ +function proxyDecodePath(pathname, search) { + const rest = pathname.slice("/api/proxy/".length); + const i1 = rest.indexOf("/"); + if (i1 < 0) return null; + const scheme = rest.slice(0, i1); + if (!/^https?$/i.test(scheme)) return null; + const after = rest.slice(i1 + 1); + const i2 = after.indexOf("/"); + const host = i2 < 0 ? after : after.slice(0, i2); + const realPath = i2 < 0 ? "/" : after.slice(i2); + if (!host) return null; + return `${scheme}://${host}${realPath}${search || ""}`; +} + +/** Rewrite root-relative / absolute ES-module import specifiers in JS to proxied + * URLs. Relative (./, ../) specifiers are left alone — they resolve correctly + * against the proxied module URL on their own. Conservative by design. */ +function rewriteJs(code, baseUrl, appOrigin) { + let out = code.replace( + /(\bfrom\s*|\bimport\s*|\bexport\s*(?:\*|\{[^}]*\})\s*from\s*)(["'])((?:\/|https?:\/\/)[^"']+)\2/g, + (m, pre, q, spec) => { + const px = proxyResolve(spec, baseUrl, appOrigin); + return px ? `${pre}${q}${px}${q}` : m; + } + ); + out = out.replace( + /\bimport\(\s*(["'])((?:\/|https?:\/\/)[^"']+)\1\s*\)/g, + (m, q, spec) => { + const px = proxyResolve(spec, baseUrl, appOrigin); + return px ? `import(${q}${px}${q})` : m; + } + ); + return out; +} + +/** Rewrite url(...) and @import targets in CSS to proxied URLs (fixes web fonts, + * which are CORS-fetched and otherwise blocked from the opaque-origin frame). */ +function rewriteCss(css, baseUrl, appOrigin) { + let out = css.replace(/url\(\s*(["']?)([^"')]+)\1\s*\)/gi, (m, q, spec) => { + if (/^data:/i.test(spec)) return m; + const px = proxyResolve(spec, baseUrl, appOrigin); + return px ? `url(${q}${px}${q})` : m; + }); + out = out.replace(/@import\s+(["'])([^"']+)\1/gi, (m, q, spec) => { + const px = proxyResolve(spec, baseUrl, appOrigin); + return px ? `@import ${q}${px}${q}` : m; + }); + return out; +} + +// Rewrite a fetched HTML document so it renders AND runs inside the browse frame: +// drop the page's own <base>/CSP, route scripts + stylesheets + fonts through our +// ACAO proxy, rewrite inline module imports and inline-style urls, then inject our +// <base href> (for un-rewritten relative links/images) and the nav bridge. +function rewriteBrowseDoc(html, finalUrl, appOrigin) { let out = html .replace(/<base\b[^>]*>/gi, "") .replace(/<meta[^>]+http-equiv\s*=\s*["']?content-security-policy["']?[^>]*>/gi, ""); - const inject = `<base href="${escapeHtmlAttr(finalUrl)}">` + browseBridgeScript(finalUrl, proxyBase); + + // <script src="…"> (classic + module) → proxied + out = out.replace( + /<script\b([^>]*?)\ssrc\s*=\s*("([^"]*)"|'([^']*)')([^>]*)>/gi, + (m, pre, _raw, dq, sq, post) => { + const spec = dq !== undefined ? dq : sq; + const px = proxyResolve(spec, finalUrl, appOrigin); + return px ? `<script${pre} src="${escapeHtmlAttr(px)}"${post}>` : m; + } + ); + + // <link rel="stylesheet|preload|modulepreload" href="…"> → proxied + out = out.replace(/<link\b[^>]*>/gi, (tag) => { + if (!/\brel\s*=\s*["']?\s*(?:stylesheet|preload|modulepreload)/i.test(tag)) return tag; + return tag.replace(/\shref\s*=\s*("([^"]*)"|'([^']*)')/i, (hm, _raw, dq, sq) => { + const spec = dq !== undefined ? dq : sq; + const px = proxyResolve(spec, finalUrl, appOrigin); + return px ? ` href="${escapeHtmlAttr(px)}"` : hm; + }); + }); + + // Inline <style>…</style> → rewrite url()/@import (web fonts) + out = out.replace( + /<style\b([^>]*)>([\s\S]*?)<\/style>/gi, + (m, attrs, body) => `<style${attrs}>${rewriteCss(body, finalUrl, appOrigin)}</style>` + ); + + // Inline <script type="module">…</script> (no src) → rewrite import specifiers + out = out.replace(/<script\b([^>]*)>([\s\S]*?)<\/script>/gi, (m, attrs, body) => { + if (/\ssrc\s*=/i.test(attrs)) return m; // external handled above + if (!/type\s*=\s*["']?module/i.test(attrs)) return m; // classic scripts unaffected + return `<script${attrs}>${rewriteJs(body, finalUrl, appOrigin)}</script>`; + }); + + const inject = + `<base href="${escapeHtmlAttr(finalUrl)}">` + + browseBridgeScript(finalUrl, appOrigin + "/api/proxy"); if (/<head[^>]*>/i.test(out)) { out = out.replace(/<head[^>]*>/i, (m) => m + inject); } else if (/<html[^>]*>/i.test(out)) { @@ -336,6 +462,61 @@ async function handleRequest(entry, req, res) { } } + // Path-style proxy for browse subresources: /api/proxy/<scheme>/<host>/<path>. + // Used for scripts, stylesheets, fonts, images referenced by a proxied page. + // Serves with Access-Control-Allow-Origin so the opaque-origin frame can load + // them, and rewrites JS imports / CSS urls so the dependency graph stays inside + // the proxy. The path layout makes relative module imports resolve correctly. + if (path.startsWith("/api/proxy/")) { + const target = proxyDecodePath(path, reqUrl.search); + if (!target) { + res.statusCode = 400; + return res.end("Bad proxy path"); + } + const appOrigin = "http://" + (req.headers.host || "127.0.0.1"); + try { + const r = await fetchUrl(target, { accept: "*/*", timeoutMs: 15000 }); + const ct = r.contentType || ""; + res.setHeader("Access-Control-Allow-Origin", "*"); + res.setHeader("Cache-Control", "public, max-age=300"); + const realPath = (() => { + try { + return new URL(r.url).pathname; + } catch { + return ""; + } + })(); + const isJs = + /javascript|ecmascript/i.test(ct) || /\.m?js($|\?)/i.test(realPath); + const isCss = /text\/css/i.test(ct) || /\.css($|\?)/i.test(realPath); + const isHtml = + /text\/html|application\/xhtml\+xml/i.test(ct) || + (!ct && /<!doctype|<html/i.test(r.body.slice(0, 256).toString("utf8"))); + if (isJs) { + res.statusCode = r.status >= 400 ? r.status : 200; + res.setHeader("Content-Type", "text/javascript; charset=utf-8"); + return res.end(rewriteJs(r.body.toString("utf8"), r.url, appOrigin)); + } + if (isCss) { + res.statusCode = r.status >= 400 ? r.status : 200; + res.setHeader("Content-Type", "text/css; charset=utf-8"); + return res.end(rewriteCss(r.body.toString("utf8"), r.url, appOrigin)); + } + if (isHtml) { + res.statusCode = 200; + res.setHeader("Content-Type", "text/html; charset=utf-8"); + return res.end(rewriteBrowseDoc(r.body.toString("utf8"), r.url, appOrigin)); + } + res.statusCode = r.status >= 400 ? r.status : 200; + res.setHeader("Content-Type", ct || "application/octet-stream"); + return res.end(r.body); + } catch (err) { + res.statusCode = 502; + res.setHeader("Access-Control-Allow-Origin", "*"); + return res.end(String(err && err.message ? err.message : err)); + } + } + if (path === "/api/proxy") { const u = reqUrl.searchParams.get("u"); if (!u) { @@ -350,6 +531,8 @@ async function handleRequest(entry, req, res) { const ct = r.contentType || ""; const isHtml = !ct || /text\/html|application\/xhtml\+xml|\/xml|text\/plain/i.test(ct); res.setHeader("Cache-Control", "no-store"); + res.setHeader("Access-Control-Allow-Origin", "*"); + const appOrigin = "http://" + (req.headers.host || "127.0.0.1"); if (!isHtml) { // Serve non-HTML targets (images, PDFs, …) verbatim so links to // them still render inside the browse frame. @@ -361,8 +544,7 @@ async function handleRequest(entry, req, res) { // X-Frame-Options / CSP, so the page is embeddable in the canvas. res.statusCode = 200; res.setHeader("Content-Type", "text/html; charset=utf-8"); - const appOrigin = "http://" + (req.headers.host || "127.0.0.1"); - return res.end(injectBrowseBridge(r.body.toString("utf8"), r.url, appOrigin + "/api/proxy")); + return res.end(rewriteBrowseDoc(r.body.toString("utf8"), r.url, appOrigin)); } catch (err) { res.statusCode = 200; res.setHeader("Content-Type", "text/html; charset=utf-8"); From 4755854a7688f820cc6cb8bfeb103cbfdb4aea94 Mon Sep 17 00:00:00 2001 From: David Pine <7679720+IEvangelist@users.noreply.github.com> Date: Wed, 1 Jul 2026 07:52:50 -0500 Subject: [PATCH 11/21] Add GitHub raw code highlighting and diagnostics AI fix prompts The code hovercard now detects github.com blob/raw URLs, fetches the corresponding raw.githubusercontent.com content, and renders it with a dependency-free multi-language syntax highlighter (markdown/MDX, markup, JSON, CSS, SQL, YAML/TOML, C-like). Failing diagnostics now include a copyable, ready-to-paste AI fix prompt describing the issue, the page URL, and the expected meta tag. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .github/extensions/og-preview/extension.mjs | 22 +- .github/extensions/og-preview/ui/app.js | 456 +++++++++++++++++++- .github/extensions/og-preview/ui/styles.css | 78 ++++ 3 files changed, 535 insertions(+), 21 deletions(-) diff --git a/.github/extensions/og-preview/extension.mjs b/.github/extensions/og-preview/extension.mjs index 777760c8f..d62f19b7e 100644 --- a/.github/extensions/og-preview/extension.mjs +++ b/.github/extensions/og-preview/extension.mjs @@ -20,6 +20,25 @@ import { parseMetadata } from "./lib/parse-og.mjs"; const UI_DIR = new URL("./ui/", import.meta.url); +// GitHub "blob" (and "raw") web URLs render an HTML page, not the file itself. +// Rewrite them to raw.githubusercontent.com so /api/raw fetches the actual +// source. Idempotent and a no-op for every other URL. +function githubBlobToRaw(value) { + try { + const u = new URL(value); + const host = u.hostname.toLowerCase(); + if (host === "github.com" || host === "www.github.com") { + const m = u.pathname.match(/^\/([^/]+)\/([^/]+)\/(?:blob|raw)\/(.+)$/); + if (m) { + return `https://raw.githubusercontent.com/${m[1]}/${m[2]}/${m[3]}`; + } + } + } catch { + /* not an absolute URL — leave as-is */ + } + return value; +} + const CONTENT_TYPES = { ".html": "text/html; charset=utf-8", ".css": "text/css; charset=utf-8", @@ -409,7 +428,8 @@ async function handleRequest(entry, req, res) { const u = reqUrl.searchParams.get("u"); if (!u) return sendJson(res, 400, { error: "Missing 'u' query parameter." }); try { - const r = await fetchUrl(u, { + const target = githubBlobToRaw(u); + const r = await fetchUrl(target, { accept: "text/plain,text/markdown,application/json,text/*;q=0.9,*/*;q=0.5", timeoutMs: 12000, maxBytes: 1024 * 1024, diff --git a/.github/extensions/og-preview/ui/app.js b/.github/extensions/og-preview/ui/app.js index 1113552e2..62542fd07 100644 --- a/.github/extensions/og-preview/ui/app.js +++ b/.github/extensions/og-preview/ui/app.js @@ -334,6 +334,351 @@ function bindImageHover(node, url) { node.addEventListener("mouseleave", hideImgTip); } +/* ---------------- Syntax highlighting (compact, dependency-free) ---------------- + A small tokenizer that colorizes source shown in the code hovercard. It's not a + full parser — it recognizes comments, strings, numbers, keywords, markup tags + and the common Markdown/MDX constructs well enough to read at a glance. Every + token is emitted as { c: className|null, v: text } and later rendered with + textContent (never innerHTML), so fetched source can't inject markup. */ + +const HL_KEYWORDS = new Set([ + "abstract", "as", "async", "await", "break", "case", "catch", "class", "const", "continue", + "debugger", "declare", "default", "delete", "do", "else", "enum", "export", "extends", "false", + "finally", "for", "from", "function", "get", "if", "implements", "import", "in", "instanceof", + "interface", "is", "keyof", "let", "namespace", "new", "null", "of", "override", "package", + "private", "protected", "public", "readonly", "return", "satisfies", "set", "static", "super", + "switch", "this", "throw", "true", "try", "type", "typeof", "undefined", "var", "void", "while", + "with", "yield", "auto", "bool", "boolean", "byte", "char", "struct", "union", "func", "fn", + "impl", "trait", "pub", "use", "mod", "match", "move", "ref", "where", "defer", "chan", "select", + "map", "range", "nil", "fun", "val", "when", "object", "sealed", "open", "internal", "operator", + "guard", "final", "throws", "using", "unsafe", "virtual", "volatile", "sizeof", "typename", + "template", "constexpr", "nullptr", "string", "int", "long", "short", "float", "double", + "unsigned", "signed", "decimal", "dynamic", "go", +]); + +const HL_HASH_KEYWORDS = new Set([ + "def", "class", "return", "if", "elif", "else", "for", "while", "in", "not", "and", "or", "is", + "import", "from", "as", "with", "try", "except", "finally", "raise", "pass", "break", "continue", + "lambda", "yield", "global", "nonlocal", "async", "await", "self", "end", "do", "then", "fi", + "done", "esac", "case", "function", "local", "export", "echo", "source", "require", "module", + "begin", "ensure", "unless", "until", "puts", "true", "false", "True", "False", "None", "nil", + "let", "set", "foreach", "param", "process", "switch", +]); + +const HL_LITERALS = new Set([ + "true", "false", "null", "undefined", "nil", "None", "True", "False", "NaN", "Infinity", +]); + +const HL_PUNCT_RE = /[{}()[\].,;:?=+\-*/%<>!&|^~]/; +const HL_ID_START = /[A-Za-z_$@]/; +const HL_ID_CHAR = /[A-Za-z0-9_$]/; +const HL_NUM_RE = + /^(?:0[xX][0-9a-fA-F_]+|0[bB][01_]+|0[oO][0-7_]+|\d[\d_]*\.?\d*(?:[eE][+-]?\d+)?|\.\d[\d_]*(?:[eE][+-]?\d+)?)[a-zA-Z%]*/; + +function hlLang(ext) { + const l = String(ext || "").toLowerCase(); + if (["md", "markdown", "mdx"].includes(l)) return "markdown"; + if (["html", "htm", "xhtml", "xml", "rss", "atom", "svg", "vue", "svelte"].includes(l)) return "markup"; + if (["json", "json5", "jsonc"].includes(l)) return "json"; + if (l === "css") return "css"; + if (["scss", "sass", "less"].includes(l)) return "scss"; + if (["sql", "graphql", "gql"].includes(l)) return "sql"; + if (["yaml", "yml", "toml", "ini", "cfg", "conf", "env", "properties", "sh", "bash", "zsh", + "fish", "ps1", "psm1", "py", "rb", "dockerfile", "makefile", "cmake", "lock", "gradle"].includes(l)) { + return "hash"; + } + if (["txt", "text", "log"].includes(l)) return "text"; + return "clike"; +} + +function hlTokens(text, ext) { + switch (hlLang(ext)) { + case "text": return [{ c: null, v: text }]; + case "markdown": return hlMarkdown(text, ext); + case "markup": return hlMarkup(text); + case "json": return hlGeneric(text, { line: ["//"], block: ["/*", "*/"], quotes: ['"'] }); + case "css": return hlGeneric(text, { line: [], block: ["/*", "*/"], quotes: ['"', "'"] }); + case "scss": return hlGeneric(text, { line: ["//"], block: ["/*", "*/"], quotes: ['"', "'"] }); + case "sql": return hlGeneric(text, { line: ["--"], block: ["/*", "*/"], quotes: ["'", '"'] }); + case "hash": return hlGeneric(text, { line: ["#"], quotes: ['"', "'"], keywords: HL_HASH_KEYWORDS }); + default: + return hlGeneric(text, { + line: ["//"], block: ["/*", "*/"], quotes: ['"', "'"], template: true, keywords: HL_KEYWORDS, + }); + } +} + +function hlGeneric(text, cfg) { + const out = []; + const push = (c, v) => { if (v) out.push({ c, v }); }; + const n = text.length; + const kw = cfg.keywords || null; + const lits = cfg.literals || HL_LITERALS; + const lineC = cfg.line || []; + const block = cfg.block || null; + const quotes = cfg.quotes || ['"', "'"]; + const template = cfg.template ? "`" : null; + let i = 0; + while (i < n) { + const ch = text[i]; + if (ch === "\n") { push(null, "\n"); i++; continue; } + if (block && text.startsWith(block[0], i)) { + let e = text.indexOf(block[1], i + block[0].length); + e = e === -1 ? n : e + block[1].length; + push("hl-com", text.slice(i, e)); i = e; continue; + } + let lc = ""; + for (const p of lineC) { if (p && text.startsWith(p, i)) { lc = p; break; } } + if (lc) { + let e = text.indexOf("\n", i); + if (e === -1) e = n; + push("hl-com", text.slice(i, e)); i = e; continue; + } + if (template && ch === template) { + let j = i + 1; + while (j < n) { if (text[j] === "\\") { j += 2; continue; } if (text[j] === template) { j++; break; } j++; } + push("hl-str", text.slice(i, j)); i = j; continue; + } + if (quotes.includes(ch)) { + let j = i + 1; + while (j < n) { + const c = text[j]; + if (c === "\\") { j += 2; continue; } + if (c === "\n") break; + if (c === ch) { j++; break; } + j++; + } + push("hl-str", text.slice(i, j)); i = j; continue; + } + if ((ch >= "0" && ch <= "9") || (ch === "." && text[i + 1] >= "0" && text[i + 1] <= "9")) { + const m = HL_NUM_RE.exec(text.substr(i, 48)); + const v = m ? m[0] : ch; + push("hl-num", v); i += v.length; continue; + } + if (HL_ID_START.test(ch)) { + let j = i + 1; + while (j < n && HL_ID_CHAR.test(text[j])) j++; + const word = text.slice(i, j); + let cls = null; + if (lits.has(word)) cls = "hl-lit"; + else if (kw && kw.has(word)) cls = "hl-kw"; + else if (text[j] === "(") cls = "hl-fn"; + push(cls, word); i = j; continue; + } + if (HL_PUNCT_RE.test(ch)) { push("hl-punct", ch); i++; continue; } + let j = i; + while (j < n) { + const c = text[j]; + if (c === "\n" || HL_ID_START.test(c) || HL_PUNCT_RE.test(c) || quotes.includes(c)) break; + if (template && c === template) break; + if (block && text.startsWith(block[0], j)) break; + let brk = false; + for (const p of lineC) { if (p && text.startsWith(p, j)) { brk = true; break; } } + if (brk) break; + j++; + } + if (j === i) j = i + 1; + push(null, text.slice(i, j)); i = j; + } + return out; +} + +function hlMarkup(text) { + const out = []; + const push = (c, v) => { if (v) out.push({ c, v }); }; + const n = text.length; + let i = 0; + while (i < n) { + const ch = text[i]; + if (text.startsWith("<!--", i)) { + let e = text.indexOf("-->", i); + e = e === -1 ? n : e + 3; + push("hl-com", text.slice(i, e)); i = e; continue; + } + if (ch === "<" && /[A-Za-z/!?]/.test(text[i + 1] || "")) { + let j = i + 1; + let lead = "<"; + if (text[j] === "/") { lead = "</"; j++; } + push("hl-punct", lead); + let s = j; + while (j < n && /[A-Za-z0-9:_.-]/.test(text[j])) j++; + push("hl-tag", text.slice(s, j)); + while (j < n && text[j] !== ">") { + const c = text[j]; + if (c === "\n") { push(null, "\n"); j++; continue; } + if (/\s/.test(c)) { + let k = j; + while (k < n && /\s/.test(text[k]) && text[k] !== "\n") k++; + push(null, text.slice(j, k)); j = k; continue; + } + if (c === "/" || c === "=") { push("hl-punct", c); j++; continue; } + if (c === '"' || c === "'") { + let k = j + 1; + while (k < n && text[k] !== c && text[k] !== "\n") k++; + if (text[k] === c) k++; + push("hl-str", text.slice(j, k)); j = k; continue; + } + if (c === "{") { + let depth = 1; + let k = j + 1; + while (k < n && depth) { if (text[k] === "{") depth++; else if (text[k] === "}") depth--; k++; } + push("hl-punct", "{"); push(null, text.slice(j + 1, k - 1)); push("hl-punct", "}"); + j = k; continue; + } + let k = j; + while (k < n && /[A-Za-z0-9:_.@-]/.test(text[k])) k++; + if (k > j) { push("hl-attr", text.slice(j, k)); j = k; } else { push(null, text[j]); j++; } + } + if (text[j] === ">") { push("hl-punct", ">"); j++; } + i = j; continue; + } + if (ch === "\n") { push(null, "\n"); i++; continue; } + let j = i; + while (j < n && text[j] !== "<" && text[j] !== "\n") j++; + push(null, text.slice(i, j)); i = j; + } + return out; +} + +function hlMdSpans(s) { + const out = []; + const n = s.length; + let i = 0; + let plainStart = 0; + const flush = (end) => { if (end > plainStart) out.push({ c: null, v: s.slice(plainStart, end) }); }; + while (i < n) { + const ch = s[i]; + if (ch === "`") { + let j = i + 1; + while (j < n && s[j] !== "`") j++; + if (j < n) j++; + flush(i); out.push({ c: "hl-code", v: s.slice(i, j) }); i = j; plainStart = i; continue; + } + if (s.startsWith("**", i) || s.startsWith("__", i)) { + const d = s.substr(i, 2); + let j = s.indexOf(d, i + 2); + if (j !== -1) { j += 2; flush(i); out.push({ c: "hl-strong", v: s.slice(i, j) }); i = j; plainStart = i; continue; } + } + if (ch === "*" || ch === "_") { + const j = s.indexOf(ch, i + 1); + if (j > i + 1) { flush(i); out.push({ c: "hl-em", v: s.slice(i, j + 1) }); i = j + 1; plainStart = i; continue; } + } + if (ch === "[" || (ch === "!" && s[i + 1] === "[")) { + const lb = ch === "!" ? i + 1 : i; + const close = s.indexOf("]", lb + 1); + if (close !== -1 && s[close + 1] === "(") { + const paren = s.indexOf(")", close + 2); + if (paren !== -1) { + flush(i); + out.push({ c: "hl-punct", v: s.slice(i, close + 1) }); + out.push({ c: "hl-punct", v: "(" }); + out.push({ c: "hl-link", v: s.slice(close + 2, paren) }); + out.push({ c: "hl-punct", v: ")" }); + i = paren + 1; plainStart = i; continue; + } + } + } + i++; + } + flush(n); + return out; +} + +function hlMdInline(line, ext) { + let m; + if ((m = line.match(/^(\s*)(#{1,6})(\s.*)?$/))) { + return [{ c: null, v: m[1] }, { c: "hl-heading", v: m[2] + (m[3] || "") }]; + } + if ((m = line.match(/^(\s*>+\s?)(.*)$/))) { + return [{ c: "hl-punct", v: m[1] }].concat(hlMdSpans(m[2])); + } + if ((m = line.match(/^(\s*)([-*+]|\d+[.)])(\s+)(.*)$/))) { + return [{ c: null, v: m[1] }, { c: "hl-punct", v: m[2] }, { c: null, v: m[3] }].concat(hlMdSpans(m[4])); + } + if (String(ext).toLowerCase() === "mdx" && /^(import|export)\b/.test(line)) { + return hlTokens(line, "ts"); + } + if (String(ext).toLowerCase() === "mdx" && /^\s*<[A-Za-z/]/.test(line)) { + return hlMarkup(line); + } + return hlMdSpans(line); +} + +function hlMarkdown(text, ext) { + const out = []; + const lines = text.split("\n"); + let inFence = false; + let fenceLang = ""; + let buf = []; + const nl = () => out.push({ c: null, v: "\n" }); + for (let idx = 0; idx < lines.length; idx++) { + const line = lines[idx]; + const fm = line.match(/^(\s*)(```|~~~)([^`~]*)$/); + if (fm) { + if (!inFence) { + if (idx > 0) nl(); + out.push({ c: "hl-punct", v: fm[1] + fm[2] }); + if (fm[3]) out.push({ c: "hl-kw", v: fm[3] }); + inFence = true; + fenceLang = (fm[3] || "").trim().split(/\s+/)[0] || ""; + buf = []; + } else { + const inner = buf.join("\n"); + const toks = fenceLang ? hlTokens(inner, fenceLang) : [{ c: null, v: inner }]; + nl(); + for (const t of toks) out.push(t); + nl(); + out.push({ c: "hl-punct", v: fm[1] + fm[2] }); + inFence = false; fenceLang = ""; buf = []; + } + continue; + } + if (inFence) { buf.push(line); continue; } + if (idx > 0) nl(); + for (const t of hlMdInline(line, ext)) out.push(t); + } + if (inFence && buf.length) { + const inner = buf.join("\n"); + const toks = fenceLang ? hlTokens(inner, fenceLang) : [{ c: null, v: inner }]; + nl(); + for (const t of toks) out.push(t); + } + return out; +} + +/* ---------------- GitHub blob → raw ---------------- + A github.com "/blob/" (or "/raw/") URL serves an HTML page, not the file. Map + it to raw.githubusercontent.com so the hovercard fetches and highlights the + actual source. Returns the input unchanged for any non-GitHub URL. */ +function isGithubCodeUrl(value) { + try { + const u = new URL(String(value).startsWith("//") ? "https:" + value : value); + const h = u.hostname.toLowerCase(); + if (h === "raw.githubusercontent.com") return true; + if ((h === "github.com" || h === "www.github.com") && /^\/[^/]+\/[^/]+\/(?:blob|raw)\//.test(u.pathname)) { + return true; + } + } catch { + /* not a URL */ + } + return false; +} + +function githubRawUrl(value) { + const s = String(value == null ? "" : value).trim(); + try { + const u = new URL(s.startsWith("//") ? "https:" + s : s); + const h = u.hostname.toLowerCase(); + if (h === "github.com" || h === "www.github.com") { + const m = u.pathname.match(/^\/([^/]+)\/([^/]+)\/(?:blob|raw)\/(.+)$/); + if (m) return "https://raw.githubusercontent.com/" + m[1] + "/" + m[2] + "/" + m[3]; + } + } catch { + /* not a URL */ + } + return s; +} + /* ---------------- Code / .mdx hover preview ---------------- Anchored, interactive (scrollable) hovercard that fetches and renders the source of a code/markdown file referenced by a value, so it can be explored @@ -365,6 +710,7 @@ function urlExt(url) { function looksLikeCode(value) { if (!/^(https?:)?\/\//i.test(value || "")) return false; if (/\.svg(\?|#|$)/i.test(value)) return false; // SVG is previewed as an image + if (isGithubCodeUrl(value)) return true; return CODE_EXT_RE.test(value); } @@ -431,9 +777,10 @@ function codeIconButton(cls, label, svg) { return b; } -function renderCodeHeader(url, payload) { - const lang = el("span", { class: "code-lang", text: codeLang(url) }); - const name = el("span", { class: "code-name", text: codeFileName(url) }); +function renderCodeHeader(url, payload, rawUrl) { + const src = rawUrl || url; + const lang = el("span", { class: "code-lang", text: codeLang(src) }); + const name = el("span", { class: "code-name", text: codeFileName(src) }); const meta = el("span", { class: "code-meta muted", text: payload && !payload.error @@ -442,7 +789,7 @@ function renderCodeHeader(url, payload) { }); const open = el("a", { class: "code-open", - href: url.startsWith("//") ? "https:" + url : url, + href: src.startsWith("//") ? "https:" + src : src, target: "_blank", rel: "noreferrer", title: "Open raw", @@ -467,18 +814,42 @@ function renderCodeHeader(url, payload) { codeHead.replaceChildren(lang, name, meta, open, copy); } -function renderCodeBody(text, truncated) { +function renderCodeBody(text, truncated, ext) { const MAX_LINES = 600; const allLines = text.replace(/\n$/, "").split("\n"); - const lines = allLines.slice(0, MAX_LINES); + const shown = allLines.slice(0, MAX_LINES); + const tokens = hlTokens(shown.join("\n"), ext); + + // Split tokens across line boundaries so multi-line tokens (block comments, + // template strings, fenced code) keep their class on every line they cover. + const lineToks = [[]]; + for (const tok of tokens) { + const parts = tok.v.split("\n"); + for (let p = 0; p < parts.length; p++) { + if (p > 0) lineToks.push([]); + if (parts[p]) lineToks[lineToks.length - 1].push({ c: tok.c, v: parts[p] }); + } + } + const wrap = el("div", { class: "code-lines" }); const frag = document.createDocumentFragment(); - lines.forEach((ln, i) => { + lineToks.forEach((toks, i) => { + const tline = el("span", { class: "cl-t" }); + if (!toks.length) { + tline.textContent = " "; + } else { + for (const t of toks) { + if (t.c) { + const s = el("span", { class: t.c }); + s.textContent = t.v; + tline.appendChild(s); + } else { + tline.appendChild(document.createTextNode(t.v)); + } + } + } frag.appendChild( - el("div", { class: "cl" }, [ - el("span", { class: "cl-n", text: String(i + 1) }), - el("span", { class: "cl-t", text: ln.length ? ln : " " }), - ]), + el("div", { class: "cl" }, [el("span", { class: "cl-n", text: String(i + 1) }), tline]), ); }); wrap.appendChild(frag); @@ -487,7 +858,7 @@ function renderCodeBody(text, truncated) { codeBody.appendChild( el("div", { class: "code-more muted", - text: `Showing ${lines.length} of ${allLines.length}${truncated ? "+" : ""} lines — open raw to see all.`, + text: `Showing ${shown.length} of ${allLines.length}${truncated ? "+" : ""} lines — open raw to see all.`, }), ); } @@ -495,30 +866,32 @@ function renderCodeBody(text, truncated) { async function showCodeCard(url, node) { hideImgTip(); + const raw = githubRawUrl(url); + const ext = urlExt(raw) || urlExt(url); const card = ensureCodeCard(); const token = ++codeReqId; - renderCodeHeader(url, null); + renderCodeHeader(url, null, raw); codeBody.replaceChildren(el("div", { class: "code-loading", text: "Loading…" })); card.hidden = false; positionCodeCard(node.getBoundingClientRect()); requestAnimationFrame(() => card.classList.add("visible")); - let payload = codeCache.get(url); + let payload = codeCache.get(raw); if (!payload) { try { - const res = await fetch("/api/raw?u=" + encodeURIComponent(url)); + const res = await fetch("/api/raw?u=" + encodeURIComponent(raw)); payload = await res.json(); } catch { payload = { error: "Couldn't load file." }; } - codeCache.set(url, payload); + codeCache.set(raw, payload); } if (token !== codeReqId || card.hidden) return; // superseded or dismissed - renderCodeHeader(url, payload); + renderCodeHeader(url, payload, raw); if (payload.error) { codeBody.replaceChildren(el("div", { class: "code-error", text: payload.error })); } else { - renderCodeBody(payload.text, payload.truncated); + renderCodeBody(payload.text, payload.truncated, ext); } positionCodeCard(node.getBoundingClientRect()); } @@ -1042,7 +1415,47 @@ const DIAG_HELP = { }, }; -function diagDetail(help) { +// Compose a copy-pasteable prompt a user can drop into any AI assistant to fix a +// failing diagnostic, seeded with the page URL and the specific issue/guidance. +function buildAiFixPrompt(check, help, url) { + const lines = []; + lines.push("Fix an OpenGraph / social-share metadata issue on my web page."); + lines.push(""); + lines.push(`Page URL: ${url || "(unknown)"}`); + lines.push(`Issue: ${check.id}${check.level ? ` (${check.level})` : ""}`); + const why = (help && help.why) || check.note; + if (why) lines.push(`Problem: ${why}`); + if (help && help.fix) lines.push(`Goal: ${help.fix}`); + if (help && help.example) { + lines.push(""); + lines.push("Reference tag:"); + lines.push(help.example); + } + lines.push(""); + lines.push( + "Give me the exact HTML <meta> tag(s) to add or change in the page <head>, " + + "using real values inferred from the page above. If you can tell the site's " + + "framework (Astro, Next.js, Hugo, plain HTML, …), show where/how to add it there; " + + "otherwise give plain HTML. Keep it concise.", + ); + return lines.join("\n"); +} + +function diagAiPrompt(check, help, url) { + const prompt = buildAiFixPrompt(check, help, url); + const head = el("div", { class: "diag-ai-head" }); + const ico = el("span", { class: "diag-ai-ico", "aria-hidden": "true" }); + ico.innerHTML = + '<svg width="14" height="14" viewBox="0 0 16 16" aria-hidden="true"><path fill="currentColor" d="M7.53 1.282a.5.5 0 0 1 .94 0l.478 1.306a7.492 7.492 0 0 0 4.464 4.464l1.305.478a.5.5 0 0 1 0 .94l-1.305.478a7.492 7.492 0 0 0-4.464 4.464l-.478 1.305a.5.5 0 0 1-.94 0l-.478-1.305a7.492 7.492 0 0 0-4.464-4.464L1.282 8.47a.5.5 0 0 1 0-.94l1.306-.478a7.492 7.492 0 0 0 4.464-4.464Z"/></svg>'; + head.append( + ico, + el("span", { class: "diag-ai-title", text: "AI fix prompt" }), + copyButton(prompt, "Copy AI prompt"), + ); + return el("div", { class: "diag-ai" }, [head, el("pre", { class: "diag-ai-body", text: prompt })]); +} + +function diagDetail(help, ctx) { const detail = el("div", { class: "diag-detail" }); detail.appendChild( el("div", { class: "diag-block" }, [ @@ -1063,6 +1476,9 @@ function diagDetail(help) { ]); detail.appendChild(snippet); } + if (ctx && ctx.check) { + detail.appendChild(diagAiPrompt(ctx.check, help, ctx.url)); + } if (help.docs) { const link = el("a", { class: "diag-docs", @@ -1125,7 +1541,7 @@ function renderDiagnostics(data) { diagChevron(), ]), ); - details.appendChild(diagDetail(help)); + details.appendChild(diagDetail(help, { check: c, url: data.requestedUrl })); return details; }), ); diff --git a/.github/extensions/og-preview/ui/styles.css b/.github/extensions/og-preview/ui/styles.css index 4c3aded50..ee7ed4947 100644 --- a/.github/extensions/og-preview/ui/styles.css +++ b/.github/extensions/og-preview/ui/styles.css @@ -1659,6 +1659,38 @@ table.kv td.v a:hover { border-top: 1px solid var(--border-soft); } +/* ---------- Syntax highlighting tokens ---------- + Colors prefer GitHub Primer prettylights tokens (which the host swaps for + dark mode automatically) and fall back to GitHub's light palette. A + body[data-mode="dark"] block supplies dark fallbacks when the tokens are + absent, so highlighting reads correctly in either theme. */ +.cl-t .hl-com { color: var(--color-prettylights-syntax-comment, #6e7781); font-style: italic; } +.cl-t .hl-kw { color: var(--color-prettylights-syntax-keyword, #cf222e); } +.cl-t .hl-str { color: var(--color-prettylights-syntax-string, #0a3069); } +.cl-t .hl-num { color: var(--color-prettylights-syntax-constant, #0550ae); } +.cl-t .hl-lit { color: var(--color-prettylights-syntax-constant, #0550ae); } +.cl-t .hl-fn { color: var(--color-prettylights-syntax-entity, #6639ba); } +.cl-t .hl-tag { color: var(--color-prettylights-syntax-entity-tag, #116329); } +.cl-t .hl-attr { color: var(--color-prettylights-syntax-constant, #0550ae); } +.cl-t .hl-punct { color: var(--text-color-muted, #59636e); } +.cl-t .hl-heading { color: var(--color-prettylights-syntax-markup-heading, #0550ae); font-weight: 600; } +.cl-t .hl-link { color: var(--color-prettylights-syntax-constant, #0a3069); text-decoration: underline; } +.cl-t .hl-code { color: var(--color-prettylights-syntax-string, #0a3069); } +.cl-t .hl-strong { font-weight: 600; } +.cl-t .hl-em { font-style: italic; } + +body[data-mode="dark"] .cl-t .hl-com { color: var(--color-prettylights-syntax-comment, #8b949e); } +body[data-mode="dark"] .cl-t .hl-kw { color: var(--color-prettylights-syntax-keyword, #ff7b72); } +body[data-mode="dark"] .cl-t .hl-str { color: var(--color-prettylights-syntax-string, #a5d6ff); } +body[data-mode="dark"] .cl-t .hl-num { color: var(--color-prettylights-syntax-constant, #79c0ff); } +body[data-mode="dark"] .cl-t .hl-lit { color: var(--color-prettylights-syntax-constant, #79c0ff); } +body[data-mode="dark"] .cl-t .hl-fn { color: var(--color-prettylights-syntax-entity, #d2a8ff); } +body[data-mode="dark"] .cl-t .hl-tag { color: var(--color-prettylights-syntax-entity-tag, #7ee787); } +body[data-mode="dark"] .cl-t .hl-attr { color: var(--color-prettylights-syntax-constant, #79c0ff); } +body[data-mode="dark"] .cl-t .hl-heading { color: var(--color-prettylights-syntax-markup-heading, #79c0ff); } +body[data-mode="dark"] .cl-t .hl-link { color: var(--color-prettylights-syntax-constant, #a5d6ff); } +body[data-mode="dark"] .cl-t .hl-code { color: var(--color-prettylights-syntax-string, #a5d6ff); } + /* ---------- Diagnostics ---------- */ #diagnostics { border: 1px solid var(--border-soft); @@ -1814,6 +1846,52 @@ details.diag-item[open] > summary .diag-chevron { opacity: 1; } +/* ---------- Diagnostics: copyable AI fix prompt ---------- */ +.diag-ai { + position: relative; + margin-top: 12px; + border: 1px solid color-mix(in srgb, var(--accent) 32%, var(--border-soft)); + border-radius: var(--radius); + background: color-mix(in srgb, var(--accent) 6%, transparent); + overflow: hidden; +} +.diag-ai-head { + display: flex; + align-items: center; + gap: 7px; + padding: 7px 8px 7px 11px; + border-bottom: 1px solid color-mix(in srgb, var(--accent) 22%, var(--border-soft)); +} +.diag-ai-ico { + display: inline-flex; + color: var(--accent); +} +.diag-ai-title { + font-size: 12px; + font-weight: var(--font-weight-semibold, 600); + color: var(--text-color-default, #1f2328); + flex: 1; +} +.diag-ai-head .copy-btn { + opacity: 0.7; +} +.diag-ai:hover .copy-btn, +.diag-ai .copy-btn:focus-visible { + opacity: 1; +} +.diag-ai-body { + margin: 0; + padding: 10px 11px; + max-height: 150px; + overflow: auto; + font-family: var(--font-mono, "SFMono-Regular", Consolas, "Liberation Mono", monospace); + font-size: 11.5px; + line-height: 1.5; + white-space: pre-wrap; + word-break: break-word; + color: var(--text-color-muted, #59636e); +} + .diag-docs { display: inline-flex; align-items: center; From 5c8b8540530df442debc4a2945660b8a3f42cd16 Mon Sep 17 00:00:00 2001 From: David Pine <7679720+IEvangelist@users.noreply.github.com> Date: Wed, 1 Jul 2026 08:18:12 -0500 Subject: [PATCH 12/21] Add repo-detected Copilot actions to diagnostics AI fix prompts Add two action buttons to the footer of each diagnostics "AI fix prompt": "Open in Copilot" (opens a Copilot coding session for the page's source repo, seeded with the prompt) and "Create issue" (files an issue on the repo and assigns Copilot). The extension auto-detects the page's GitHub source repo by scanning the full fetched HTML (edit/blob/tree/commit/raw links), not just OG metadata. When no repo is found, the footer shows a muted note instead of buttons. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .github/extensions/og-preview/extension.mjs | 113 ++++++++++++++++++ .../extensions/og-preview/lib/parse-og.mjs | 70 +++++++++++ .github/extensions/og-preview/ui/app.js | 106 +++++++++++++++- .github/extensions/og-preview/ui/styles.css | 94 ++++++++++++++- 4 files changed, 378 insertions(+), 5 deletions(-) diff --git a/.github/extensions/og-preview/extension.mjs b/.github/extensions/og-preview/extension.mjs index d62f19b7e..fd0ede3c9 100644 --- a/.github/extensions/og-preview/extension.mjs +++ b/.github/extensions/og-preview/extension.mjs @@ -96,6 +96,78 @@ function sendJson(res, status, obj) { res.end(JSON.stringify(obj)); } +function readJsonBody(req, limit = 512 * 1024) { + return new Promise((resolve, reject) => { + let size = 0; + const chunks = []; + req.on("data", (c) => { + size += c.length; + if (size > limit) { + reject(new Error("Request body too large.")); + req.destroy(); + return; + } + chunks.push(c); + }); + req.on("end", () => { + if (!chunks.length) return resolve({}); + try { + resolve(JSON.parse(Buffer.concat(chunks).toString("utf8"))); + } catch { + reject(new Error("Invalid JSON body.")); + } + }); + req.on("error", reject); + }); +} + +// Instruction posted back into the host chat session (via session.send) when the +// user clicks "Open in Copilot" on a diagnostics fix prompt. The agent turns this +// into a real coding session for the repo, seeded with the fix prompt. +function buildOpenSessionMessage(repo, pageUrl, title, prompt) { + const lines = [ + "[OG Viewer canvas action: open-session]", + `The user clicked "Open in Copilot" on an OpenGraph diagnostics fix prompt in the OG Viewer canvas.`, + "", + `Target repository: ${repo}`, + `Page URL: ${pageUrl || "(unknown)"}`, + ]; + if (title) lines.push(`Diagnostic: ${title}`); + lines.push( + "", + `Please open a GitHub Copilot App coding session for \`${repo}\`. Match it to one of my configured projects; if a project/checkout for this repo already exists, add a new session to it, otherwise create the session for that repo. Use the AI fix prompt below verbatim as the session's kickoff prompt so it starts working on the fix, and follow my usual account, branch-naming, and PR/push conventions.`, + "", + "AI fix prompt to seed the new session with:", + "----------------------------------------", + prompt, + "----------------------------------------", + ); + return lines.join("\n"); +} + +// Instruction posted back into the host chat session when the user clicks +// "Create issue". The agent files the issue and assigns it to Copilot. +function buildCreateIssueMessage(repo, pageUrl, title, prompt) { + const lines = [ + "[OG Viewer canvas action: create-issue]", + `The user clicked "Create issue" on an OpenGraph diagnostics fix prompt in the OG Viewer canvas.`, + "", + `Target repository: ${repo}`, + `Page URL: ${pageUrl || "(unknown)"}`, + ]; + if (title) lines.push(`Suggested issue title: ${title}`); + lines.push( + "", + `Please create a new GitHub issue on \`${repo}\` describing this OpenGraph metadata problem. Use the AI fix prompt below as the issue body (prepend a short line noting it came from the OG Viewer for ${pageUrl || "the page"}). After creating it, assign the issue to the Copilot coding agent, then reply with the issue URL. Use the correct account for that repo per my conventions.`, + "", + "Issue body (AI fix prompt):", + "----------------------------------------", + prompt, + "----------------------------------------", + ); + return lines.join("\n"); +} + function escapeHtmlAttr(s) { return String(s) .replace(/&/g, "&") @@ -573,6 +645,47 @@ async function handleRequest(entry, req, res) { } } + if (path === "/api/open-session" || path === "/api/create-issue") { + if (req.method !== "POST") { + return sendJson(res, 405, { error: "Use POST." }); + } + let body; + try { + body = await readJsonBody(req); + } catch (err) { + return sendJson(res, 400, { error: err.message }); + } + const repo = typeof body.repo === "string" ? body.repo.trim() : ""; + const pageUrl = typeof body.url === "string" ? body.url.trim() : ""; + const prompt = typeof body.prompt === "string" ? body.prompt : ""; + const title = typeof body.title === "string" ? body.title.trim() : ""; + if (!repo || !/^[^/\s]+\/[^/\s]+$/.test(repo)) { + return sendJson(res, 400, { error: "A valid 'owner/repo' is required." }); + } + if (!prompt) { + return sendJson(res, 400, { error: "A 'prompt' is required." }); + } + const kind = path === "/api/open-session" ? "open-session" : "create-issue"; + const message = + kind === "open-session" + ? buildOpenSessionMessage(repo, pageUrl, title, prompt) + : buildCreateIssueMessage(repo, pageUrl, title, prompt); + try { + if (!sessionRef || typeof sessionRef.send !== "function") { + return sendJson(res, 200, { ok: false, error: "Session bridge unavailable." }); + } + // Fire the request into the host chat session; the agent acts on it. + sessionRef.send(message).catch(() => {}); + log(`OG Viewer: requested ${kind} for ${repo}.`); + return sendJson(res, 200, { ok: true }); + } catch (err) { + return sendJson(res, 200, { + ok: false, + error: err && err.message ? err.message : String(err), + }); + } + } + res.statusCode = 404; res.end("Not found"); } diff --git a/.github/extensions/og-preview/lib/parse-og.mjs b/.github/extensions/og-preview/lib/parse-og.mjs index 5e07dde37..ff080d17b 100644 --- a/.github/extensions/og-preview/lib/parse-og.mjs +++ b/.github/extensions/og-preview/lib/parse-og.mjs @@ -52,6 +52,75 @@ function resolveUrl(value, baseUrl) { } } +// GitHub path segments that are never a real "owner" (site pages, product +// areas, etc.). Used to filter false positives when sniffing a repo link. +const GH_RESERVED_OWNERS = new Set([ + "about", "account", "admin", "apps", "assets", "blog", "business", "careers", + "cdn", "collections", "contact", "customer-stories", "dashboard", "enterprise", + "events", "explore", "features", "fluidicon", "github", "home", "join", "login", + "logout", "marketplace", "mobile", "new", "notifications", "open-source", "orgs", + "personal", "pricing", "pulls", "readme", "search", "security", "sessions", + "settings", "showcases", "signup", "site", "sponsors", "stars", "team", "teams", + "topics", "trending", "user", "users", "watching", "wiki", "codespaces", "copilot", +]); + +function scoreRepoPath(rest) { + // "Edit this page" links are the strongest signal that a repo builds THIS + // page; blob/tree/raw and commit links are next; issue/release links weakest. + if (/^\/edit\//i.test(rest)) return 100; + if (/^\/(?:blob|tree|raw|blame)\//i.test(rest)) return 60; + if (/^\/(?:commit|commits)\b/i.test(rest)) return 40; + if (/^\/(?:releases|tags|issues|pull|pulls|wiki|actions|discussions|graphs|network)\b/i.test(rest)) return 8; + return 4; // bare repo link +} + +function addRepoCandidate(scores, owner, repo, score) { + if (!owner || !repo) return; + owner = owner.trim(); + repo = repo.replace(/\.git$/i, "").replace(/[.\-_]+$/, "").trim(); + if (!owner || !repo) return; + if (GH_RESERVED_OWNERS.has(owner.toLowerCase())) return; + if (/^(?:sponsors|apps|orgs|followers|following)$/i.test(repo)) return; + const key = `${owner}/${repo}`; + const cur = scores.get(key) || { owner, repo, score: 0, hits: 0 }; + cur.score += score; + cur.hits += 1; + scores.set(key, cur); +} + +/** + * Best-effort detection of the GitHub source repository for a page by scanning + * its full HTML for repo links (not just OpenGraph tags). "Edit this page", + * blob/tree, and commit links are the strongest signals for the repo that + * actually builds the page. + * @param {string} html + * @returns {{ owner: string, repo: string, slug: string, url: string } | null} + */ +export function detectRepository(html) { + if (!html) return null; + const scores = new Map(); + const ghRe = /\bgithub\.com\/([A-Za-z0-9][A-Za-z0-9-]{0,38})\/([A-Za-z0-9._-]+)((?:\/[^\s"'<>)]*)?)/gi; + let m; + while ((m = ghRe.exec(html)) !== null) { + addRepoCandidate(scores, m[1], m[2], scoreRepoPath(m[3] || "")); + } + // raw.githubusercontent.com/<owner>/<repo>/<ref>/... — treat like a blob link. + const rawRe = /\braw\.githubusercontent\.com\/([A-Za-z0-9][A-Za-z0-9-]{0,38})\/([A-Za-z0-9._-]+)\//gi; + while ((m = rawRe.exec(html)) !== null) { + addRepoCandidate(scores, m[1], m[2], 60); + } + if (scores.size === 0) return null; + let best = null; + for (const v of scores.values()) { + if (!best || v.score > best.score || (v.score === best.score && v.hits > best.hits)) { + best = v; + } + } + if (!best) return null; + const slug = `${best.owner}/${best.repo}`; + return { owner: best.owner, repo: best.repo, slug, url: `https://github.com/${slug}` }; +} + /** * Parse OpenGraph and related metadata out of an HTML document. * @param {string} html @@ -170,6 +239,7 @@ export function parseMetadata(html, baseUrl) { diagnostics, htmlTitle, tagCount: all.length, + repository: detectRepository(html), }; } diff --git a/.github/extensions/og-preview/ui/app.js b/.github/extensions/og-preview/ui/app.js index 62542fd07..ccee47e32 100644 --- a/.github/extensions/og-preview/ui/app.js +++ b/.github/extensions/og-preview/ui/app.js @@ -55,6 +55,12 @@ const OCTICONS = { '<path fill="currentColor" d="M5.75 2.5h8.5a.75.75 0 0 1 0 1.5h-8.5a.75.75 0 0 1 0-1.5Zm0 5h8.5a.75.75 0 0 1 0 1.5h-8.5a.75.75 0 0 1 0-1.5Zm0 5h8.5a.75.75 0 0 1 0 1.5h-8.5a.75.75 0 0 1 0-1.5ZM2 14a1 1 0 1 1 0-2 1 1 0 0 1 0 2Zm1-6a1 1 0 1 1-2 0 1 1 0 0 1 2 0ZM2 4a1 1 0 1 1 0-2 1 1 0 0 1 0 2Z"/>', grid: '<rect x="1.75" y="1.75" width="5.25" height="5.25" rx="1.4" fill="currentColor"/><rect x="9" y="1.75" width="5.25" height="5.25" rx="1.4" fill="currentColor"/><rect x="1.75" y="9" width="5.25" height="5.25" rx="1.4" fill="currentColor"/><rect x="9" y="9" width="5.25" height="5.25" rx="1.4" fill="currentColor"/>', + "mark-github": + '<path fill="currentColor" d="M8 0c4.42 0 8 3.58 8 8a8.013 8.013 0 0 1-5.45 7.59c-.4.08-.55-.17-.55-.38 0-.27.01-1.13.01-2.2 0-.75-.25-1.23-.54-1.48 1.78-.2 3.65-.88 3.65-3.95 0-.88-.31-1.59-.82-2.15.08-.2.36-1.02-.08-2.12 0 0-.67-.22-2.2.82-.64-.18-1.32-.27-2-.27-.68 0-1.36.09-2 .27-1.53-1.03-2.2-.82-2.2-.82-.44 1.1-.16 1.92-.08 2.12-.51.56-.82 1.28-.82 2.15 0 3.06 1.86 3.75 3.64 3.95-.23.2-.44.55-.51 1.07-.46.21-1.61.55-2.33-.66-.15-.24-.6-.83-1.23-.82-.67.01-.27.38.01.53.34.19.73.9.82 1.13.16.45.68 1.31 2.69.94 0 .67.01 1.3.01 1.49 0 .21-.15.45-.55.38A8.013 8.013 0 0 1 0 8c0-4.42 3.58-8 8-8Z"/>', + "device-desktop": + '<path fill="currentColor" d="M14.25 1c.966 0 1.75.784 1.75 1.75v7.5A1.75 1.75 0 0 1 14.25 12h-3.727c.099 1.041.52 1.872 1.263 2.516a.75.75 0 0 1-.49 1.317h-6.6a.75.75 0 0 1-.49-1.317c.744-.644 1.164-1.475 1.263-2.516H1.75A1.75 1.75 0 0 1 0 10.25v-7.5C0 1.784.784 1 1.75 1ZM1.75 2.5a.25.25 0 0 0-.25.25v7.5c0 .138.112.25.25.25h12.5a.25.25 0 0 0 .25-.25v-7.5a.25.25 0 0 0-.25-.25Z"/>', + "issue-opened": + '<path fill="currentColor" d="M8 9.5a1.5 1.5 0 1 0 0-3 1.5 1.5 0 0 0 0 3Z"/><path fill="currentColor" d="M8 0a8 8 0 1 1 0 16A8 8 0 0 1 8 0ZM1.5 8a6.5 6.5 0 1 0 13 0 6.5 6.5 0 0 0-13 0Z"/>', }; function octicon(name, size, cls) { @@ -1441,7 +1447,7 @@ function buildAiFixPrompt(check, help, url) { return lines.join("\n"); } -function diagAiPrompt(check, help, url) { +function diagAiPrompt(check, help, url, repo) { const prompt = buildAiFixPrompt(check, help, url); const head = el("div", { class: "diag-ai-head" }); const ico = el("span", { class: "diag-ai-ico", "aria-hidden": "true" }); @@ -1452,7 +1458,99 @@ function diagAiPrompt(check, help, url) { el("span", { class: "diag-ai-title", text: "AI fix prompt" }), copyButton(prompt, "Copy AI prompt"), ); - return el("div", { class: "diag-ai" }, [head, el("pre", { class: "diag-ai-body", text: prompt })]); + const children = [head, el("pre", { class: "diag-ai-body", text: prompt })]; + children.push(diagAiFooter(check, prompt, url, repo)); + return el("div", { class: "diag-ai" }, children); +} + +// Footer under the AI prompt. When a source repo was detected in the page, it +// offers two Copilot actions: open a coding session seeded with the prompt, or +// file an issue and hand it to the Copilot agent. Both round-trip through the +// extension's loopback API, which asks the host chat session to do the work. +function diagAiFooter(check, prompt, url, repo) { + const foot = el("div", { class: "diag-ai-foot" }); + if (!repo || !repo.owner || !repo.repo) { + foot.classList.add("diag-ai-foot-empty"); + foot.append( + el("span", { + class: "diag-ai-foot-note", + text: "No source GitHub repo found in this page — copy the prompt above to use it manually.", + }), + ); + return foot; + } + const slug = `${repo.owner}/${repo.repo}`; + const title = `Fix OpenGraph metadata: ${check.id}`; + const repoTag = el("span", { class: "diag-ai-repo", title: repo.url || slug }, [ + octicon("mark-github", 13, "diag-ai-repo-ico"), + el("span", { class: "diag-ai-repo-slug", text: slug }), + ]); + + const openBtn = diagActionButton("Open in Copilot", "device-desktop", (btn) => { + copyText(prompt); + postAction("/api/open-session", { repo: slug, url, prompt, title }, btn, "Opening…", "Session requested"); + }); + openBtn.title = `Copy the prompt and open a Copilot session for ${slug}`; + + const issueBtn = diagActionButton("Create issue", "issue-opened", (btn) => { + postAction("/api/create-issue", { repo: slug, url, prompt, title }, btn, "Filing…", "Issue requested"); + }); + issueBtn.title = `Open an issue on ${slug} and assign it to Copilot`; + + foot.append(repoTag, el("span", { class: "diag-ai-foot-spacer" }), openBtn, issueBtn); + return foot; +} + +function diagActionButton(label, iconName, onClick) { + const btn = el("button", { class: "diag-ai-btn", type: "button" }); + btn.append( + octicon(iconName, 14, "diag-ai-btn-ico"), + el("span", { class: "diag-ai-btn-label", text: label }), + ); + btn.addEventListener("click", (e) => { + e.preventDefault(); + e.stopPropagation(); + if (btn.disabled) return; + onClick(btn); + }); + return btn; +} + +async function postAction(path, payload, btn, busyLabel, doneLabel) { + const labelEl = btn.querySelector(".diag-ai-btn-label"); + const orig = labelEl ? labelEl.textContent : ""; + const reset = (delay) => + setTimeout(() => { + btn.classList.remove("busy", "done", "error"); + btn.disabled = false; + if (labelEl) labelEl.textContent = orig; + }, delay); + btn.disabled = true; + btn.classList.add("busy"); + if (labelEl && busyLabel) labelEl.textContent = busyLabel; + try { + const res = await fetch(path, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(payload), + }); + let ok = res.ok; + try { + const data = await res.json(); + if (data && data.ok === false) ok = false; + } catch { + /* ignore body parse errors */ + } + btn.classList.remove("busy"); + btn.classList.add(ok ? "done" : "error"); + if (labelEl) labelEl.textContent = ok ? doneLabel || "Done" : "Failed"; + reset(ok ? 3200 : 2600); + } catch { + btn.classList.remove("busy"); + btn.classList.add("error"); + if (labelEl) labelEl.textContent = "Failed"; + reset(2600); + } } function diagDetail(help, ctx) { @@ -1477,7 +1575,7 @@ function diagDetail(help, ctx) { detail.appendChild(snippet); } if (ctx && ctx.check) { - detail.appendChild(diagAiPrompt(ctx.check, help, ctx.url)); + detail.appendChild(diagAiPrompt(ctx.check, help, ctx.url, ctx && ctx.repo)); } if (help.docs) { const link = el("a", { @@ -1541,7 +1639,7 @@ function renderDiagnostics(data) { diagChevron(), ]), ); - details.appendChild(diagDetail(help, { check: c, url: data.requestedUrl })); + details.appendChild(diagDetail(help, { check: c, url: data.requestedUrl, repo: data.repository })); return details; }), ); diff --git a/.github/extensions/og-preview/ui/styles.css b/.github/extensions/og-preview/ui/styles.css index ee7ed4947..d75a6fd45 100644 --- a/.github/extensions/og-preview/ui/styles.css +++ b/.github/extensions/og-preview/ui/styles.css @@ -1892,7 +1892,99 @@ details.diag-item[open] > summary .diag-chevron { color: var(--text-color-muted, #59636e); } -.diag-docs { +.diag-ai-foot { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 8px; + margin-top: 10px; + padding-top: 10px; + border-top: 1px solid var(--border-soft, rgba(0, 0, 0, 0.08)); +} + +.diag-ai-foot-spacer { + flex: 1 1 auto; +} + +.diag-ai-foot-empty { + border-top: none; + padding-top: 4px; + margin-top: 6px; +} + +.diag-ai-foot-note { + font-size: 11.5px; + color: var(--text-color-muted, #59636e); +} + +.diag-ai-repo { + display: inline-flex; + align-items: center; + gap: 5px; + min-width: 0; + font-family: var(--font-mono, "SFMono-Regular", Consolas, "Liberation Mono", monospace); + font-size: 11.5px; + color: var(--text-color-muted, #59636e); +} + +.diag-ai-repo-ico { + flex: 0 0 auto; + opacity: 0.8; +} + +.diag-ai-repo-slug { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.diag-ai-btn { + display: inline-flex; + align-items: center; + gap: 5px; + padding: 4px 10px; + border: 1px solid var(--border-soft, rgba(0, 0, 0, 0.12)); + border-radius: 6px; + background: var(--surface-inset, rgba(0, 0, 0, 0.03)); + color: var(--text-color-default, #1f2328); + font-size: 12px; + font-weight: 500; + line-height: 1.2; + cursor: pointer; + transition: background 0.12s ease, border-color 0.12s ease, color 0.12s ease; +} + +.diag-ai-btn:hover:not(:disabled) { + background: color-mix(in srgb, var(--accent, #0969da) 12%, transparent); + border-color: color-mix(in srgb, var(--accent, #0969da) 40%, var(--border-soft, rgba(0, 0, 0, 0.12))); +} + +.diag-ai-btn:disabled { + cursor: default; +} + +.diag-ai-btn-ico { + flex: 0 0 auto; + opacity: 0.9; +} + +.diag-ai-btn.busy { + opacity: 0.75; +} + +.diag-ai-btn.done { + color: var(--true-color-green, #1a7f37); + border-color: color-mix(in srgb, var(--true-color-green, #1a7f37) 45%, transparent); + background: color-mix(in srgb, var(--true-color-green, #1a7f37) 12%, transparent); +} + +.diag-ai-btn.error { + color: var(--true-color-red, #cf222e); + border-color: color-mix(in srgb, var(--true-color-red, #cf222e) 45%, transparent); + background: color-mix(in srgb, var(--true-color-red, #cf222e) 12%, transparent); +} + + display: inline-flex; align-items: center; gap: 5px; From 3170c14d9cc1b4fb558a0751d3e29a91b3d86733 Mon Sep 17 00:00:00 2001 From: David Pine <7679720+IEvangelist@users.noreply.github.com> Date: Wed, 1 Jul 2026 08:34:26 -0500 Subject: [PATCH 13/21] Polish diagnostics AI fix prompt footer + unconfigured-repo note MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - ui/styles.css: give the AI fix prompt footer proper horizontal + vertical inset (padding: 9px 11px) aligned with the prompt body, an accent-tinted top border, and a fixed empty/no-repo note state; restore the .diag-docs selector that a prior edit accidentally clobbered (its declarations were orphaned, so the "Open Graph …" docs link lost its top margin and touched the AI prompt box); align the AI-prompt-header copy button with the snippet copy button (head padding-right 8px -> 6px so both sit 7px from the box's right edge). - extension.mjs: the "Open in Copilot" injected instruction (buildOpenSessionMessage) now explicitly tells the agent that if the detected repo is NOT one of the user's configured projects, it must say so plainly and offer alternatives (use "Create issue", or add the repo as a project) rather than improvising a sandbox session. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .github/extensions/og-preview/extension.mjs | 4 +++- .github/extensions/og-preview/ui/styles.css | 14 ++++++-------- 2 files changed, 9 insertions(+), 9 deletions(-) diff --git a/.github/extensions/og-preview/extension.mjs b/.github/extensions/og-preview/extension.mjs index fd0ede3c9..1f92d82ea 100644 --- a/.github/extensions/og-preview/extension.mjs +++ b/.github/extensions/og-preview/extension.mjs @@ -135,7 +135,9 @@ function buildOpenSessionMessage(repo, pageUrl, title, prompt) { if (title) lines.push(`Diagnostic: ${title}`); lines.push( "", - `Please open a GitHub Copilot App coding session for \`${repo}\`. Match it to one of my configured projects; if a project/checkout for this repo already exists, add a new session to it, otherwise create the session for that repo. Use the AI fix prompt below verbatim as the session's kickoff prompt so it starts working on the fix, and follow my usual account, branch-naming, and PR/push conventions.`, + `Please open a GitHub Copilot App coding session for \`${repo}\`. First check whether \`${repo}\` maps to one of my configured projects: if a project/checkout for this repo already exists, add a new session to it; if it exists but has no session yet, create one for it. Use the AI fix prompt below verbatim as the session's kickoff prompt so it starts working on the fix, and follow my usual account, branch-naming, and PR/push conventions.`, + "", + `If \`${repo}\` is NOT one of my configured projects, do not improvise a sandbox/scratch session for it. Instead, tell me plainly that it isn't configured and offer alternatives — e.g. use the "Create issue" button to file an issue on the repo and assign Copilot, or add \`${repo}\` as a project first and then open the session.`, "", "AI fix prompt to seed the new session with:", "----------------------------------------", diff --git a/.github/extensions/og-preview/ui/styles.css b/.github/extensions/og-preview/ui/styles.css index d75a6fd45..5994d2afc 100644 --- a/.github/extensions/og-preview/ui/styles.css +++ b/.github/extensions/og-preview/ui/styles.css @@ -1859,7 +1859,7 @@ details.diag-item[open] > summary .diag-chevron { display: flex; align-items: center; gap: 7px; - padding: 7px 8px 7px 11px; + padding: 7px 6px 7px 11px; border-bottom: 1px solid color-mix(in srgb, var(--accent) 22%, var(--border-soft)); } .diag-ai-ico { @@ -1897,9 +1897,8 @@ details.diag-item[open] > summary .diag-chevron { flex-wrap: wrap; align-items: center; gap: 8px; - margin-top: 10px; - padding-top: 10px; - border-top: 1px solid var(--border-soft, rgba(0, 0, 0, 0.08)); + padding: 9px 11px; + border-top: 1px solid color-mix(in srgb, var(--accent) 22%, var(--border-soft)); } .diag-ai-foot-spacer { @@ -1907,9 +1906,8 @@ details.diag-item[open] > summary .diag-chevron { } .diag-ai-foot-empty { - border-top: none; - padding-top: 4px; - margin-top: 6px; + padding-top: 8px; + padding-bottom: 8px; } .diag-ai-foot-note { @@ -1984,7 +1982,7 @@ details.diag-item[open] > summary .diag-chevron { background: color-mix(in srgb, var(--true-color-red, #cf222e) 12%, transparent); } - +.diag-docs { display: inline-flex; align-items: center; gap: 5px; From 4cc657c58314dbdc7c9b0dddf3af85b9dddbd74b Mon Sep 17 00:00:00 2001 From: David Pine <7679720+IEvangelist@users.noreply.github.com> Date: Wed, 1 Jul 2026 08:46:47 -0500 Subject: [PATCH 14/21] Use official Copilot/issue icons, link the repo slug, and fix scrollbar carets MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - ui/app.js: replace the "Open in Copilot" button's device-desktop octicon with the official GitHub Copilot octicon (copilot-16); the "Create issue" button now uses the official issue-opened-16 path; remove the now-unused device-desktop entry from the OCTICONS map. The detected-repo footer slug is now a real link (span.diag-ai-repo -> a.diag-ai-repo with href, target=_blank, rel=noreferrer). - ui/styles.css: (1) Scrollbar carets fix — Chromium M121+ (the Copilot App WebView) ignores all ::-webkit-scrollbar pseudo-elements on any element that also sets standard scrollbar-width/scrollbar-color, so the existing ::-webkit-scrollbar-button{display:none} was ignored and stepper carets rendered. Move scrollbar-width/scrollbar-color into an @supports(-moz-appearance:none) (Firefox-only) block and keep the fully custom ::-webkit-scrollbar rules (all -button variants display:none) for Chromium. (2) Add link styling for a.diag-ai-repo (hover accent + underline slug, focus-visible outline). Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .github/extensions/og-preview/ui/app.js | 14 +++++-- .github/extensions/og-preview/ui/styles.css | 45 ++++++++++++++++++--- 2 files changed, 49 insertions(+), 10 deletions(-) diff --git a/.github/extensions/og-preview/ui/app.js b/.github/extensions/og-preview/ui/app.js index ccee47e32..b9d8262e4 100644 --- a/.github/extensions/og-preview/ui/app.js +++ b/.github/extensions/og-preview/ui/app.js @@ -57,10 +57,10 @@ const OCTICONS = { '<rect x="1.75" y="1.75" width="5.25" height="5.25" rx="1.4" fill="currentColor"/><rect x="9" y="1.75" width="5.25" height="5.25" rx="1.4" fill="currentColor"/><rect x="1.75" y="9" width="5.25" height="5.25" rx="1.4" fill="currentColor"/><rect x="9" y="9" width="5.25" height="5.25" rx="1.4" fill="currentColor"/>', "mark-github": '<path fill="currentColor" d="M8 0c4.42 0 8 3.58 8 8a8.013 8.013 0 0 1-5.45 7.59c-.4.08-.55-.17-.55-.38 0-.27.01-1.13.01-2.2 0-.75-.25-1.23-.54-1.48 1.78-.2 3.65-.88 3.65-3.95 0-.88-.31-1.59-.82-2.15.08-.2.36-1.02-.08-2.12 0 0-.67-.22-2.2.82-.64-.18-1.32-.27-2-.27-.68 0-1.36.09-2 .27-1.53-1.03-2.2-.82-2.2-.82-.44 1.1-.16 1.92-.08 2.12-.51.56-.82 1.28-.82 2.15 0 3.06 1.86 3.75 3.64 3.95-.23.2-.44.55-.51 1.07-.46.21-1.61.55-2.33-.66-.15-.24-.6-.83-1.23-.82-.67.01-.27.38.01.53.34.19.73.9.82 1.13.16.45.68 1.31 2.69.94 0 .67.01 1.3.01 1.49 0 .21-.15.45-.55.38A8.013 8.013 0 0 1 0 8c0-4.42 3.58-8 8-8Z"/>', - "device-desktop": - '<path fill="currentColor" d="M14.25 1c.966 0 1.75.784 1.75 1.75v7.5A1.75 1.75 0 0 1 14.25 12h-3.727c.099 1.041.52 1.872 1.263 2.516a.75.75 0 0 1-.49 1.317h-6.6a.75.75 0 0 1-.49-1.317c.744-.644 1.164-1.475 1.263-2.516H1.75A1.75 1.75 0 0 1 0 10.25v-7.5C0 1.784.784 1 1.75 1ZM1.75 2.5a.25.25 0 0 0-.25.25v7.5c0 .138.112.25.25.25h12.5a.25.25 0 0 0 .25-.25v-7.5a.25.25 0 0 0-.25-.25Z"/>', "issue-opened": '<path fill="currentColor" d="M8 9.5a1.5 1.5 0 1 0 0-3 1.5 1.5 0 0 0 0 3Z"/><path fill="currentColor" d="M8 0a8 8 0 1 1 0 16A8 8 0 0 1 8 0ZM1.5 8a6.5 6.5 0 1 0 13 0 6.5 6.5 0 0 0-13 0Z"/>', + copilot: + '<path fill="currentColor" d="M7.998 15.035c-4.562 0-7.873-2.914-7.998-3.749V9.338c.085-.628.677-1.686 1.588-2.065.013-.07.024-.143.036-.218.029-.183.06-.384.126-.612-.201-.508-.254-1.084-.254-1.656 0-.87.128-1.769.693-2.484.579-.733 1.494-1.124 2.724-1.261 1.206-.134 2.262.034 2.944.765.05.053.096.108.139.165.044-.057.094-.112.143-.165.682-.731 1.738-.899 2.944-.765 1.23.137 2.145.528 2.724 1.261.566.715.693 1.614.693 2.484 0 .572-.053 1.148-.254 1.656.066.228.098.429.126.612.012.076.024.148.037.218.924.385 1.522 1.471 1.591 2.095v1.872c0 .766-3.351 3.795-8.002 3.795Zm0-1.485c2.28 0 4.584-1.11 5.002-1.433V7.862l-.023-.116c-.49.21-1.075.291-1.727.291-1.146 0-2.059-.327-2.71-.991A3.222 3.222 0 0 1 8 6.303a3.24 3.24 0 0 1-.544.743c-.65.664-1.563.991-2.71.991-.652 0-1.236-.081-1.727-.291l-.023.116v4.255c.419.323 2.722 1.433 5.002 1.433ZM6.762 2.83c-.193-.206-.637-.413-1.682-.297-1.019.113-1.479.404-1.713.7-.247.312-.369.789-.369 1.554 0 .793.129 1.171.308 1.371.162.181.519.379 1.442.379.853 0 1.339-.235 1.638-.54.315-.322.527-.827.617-1.553.117-.935-.037-1.395-.241-1.614Zm4.155-.297c-1.044-.116-1.488.091-1.681.297-.204.219-.359.679-.242 1.614.091.726.303 1.231.618 1.553.299.305.784.54 1.638.54.922 0 1.28-.198 1.442-.379.179-.2.308-.578.308-1.371 0-.765-.123-1.242-.37-1.554-.233-.296-.693-.587-1.713-.7Z"/><path fill="currentColor" d="M6.25 9.037a.75.75 0 0 1 .75.75v1.501a.75.75 0 0 1-1.5 0V9.787a.75.75 0 0 1 .75-.75Zm4.25.75v1.501a.75.75 0 0 1-1.5 0V9.787a.75.75 0 0 1 1.5 0Z"/>', }; function octicon(name, size, cls) { @@ -1481,12 +1481,18 @@ function diagAiFooter(check, prompt, url, repo) { } const slug = `${repo.owner}/${repo.repo}`; const title = `Fix OpenGraph metadata: ${check.id}`; - const repoTag = el("span", { class: "diag-ai-repo", title: repo.url || slug }, [ + const repoTag = el("a", { + class: "diag-ai-repo", + href: repo.url || `https://github.com/${slug}`, + target: "_blank", + rel: "noreferrer", + title: `Open ${slug} on GitHub`, + }, [ octicon("mark-github", 13, "diag-ai-repo-ico"), el("span", { class: "diag-ai-repo-slug", text: slug }), ]); - const openBtn = diagActionButton("Open in Copilot", "device-desktop", (btn) => { + const openBtn = diagActionButton("Open in Copilot", "copilot", (btn) => { copyText(prompt); postAction("/api/open-session", { repo: slug, url, prompt, title }, btn, "Opening…", "Session requested"); }); diff --git a/.github/extensions/og-preview/ui/styles.css b/.github/extensions/og-preview/ui/styles.css index 5994d2afc..3500450e4 100644 --- a/.github/extensions/og-preview/ui/styles.css +++ b/.github/extensions/og-preview/ui/styles.css @@ -61,11 +61,15 @@ } /* App-style scrollbars: thin, rounded thumb, transparent track, and NO stepper - (up/down chevron) buttons at the ends. Applied to every scroll container. */ -* { - scrollbar-width: thin; - scrollbar-color: var(--scrollbar-thumb) transparent; -} + (up/down chevron) buttons at the ends. Applied to every scroll container. + + NOTE: Chromium (M121+) — which the Copilot App WebView uses — DISABLES all + ::-webkit-scrollbar pseudo-elements on any element that also sets the standard + `scrollbar-width` or `scrollbar-color`. That standard scrollbar renders stepper + carets on Windows. So we must NOT set those standard props globally; we scope + them to Firefox only (via @supports) and let Chromium/WebKit use the fully + custom ::-webkit-scrollbar below, where `-button { display:none }` truly + removes the carets. */ *::-webkit-scrollbar { width: 11px; height: 11px; @@ -83,7 +87,12 @@ background: var(--scrollbar-thumb-hover); background-clip: padding-box; } -*::-webkit-scrollbar-button { +*::-webkit-scrollbar-button, +*::-webkit-scrollbar-button:single-button, +*::-webkit-scrollbar-button:vertical:decrement, +*::-webkit-scrollbar-button:vertical:increment, +*::-webkit-scrollbar-button:horizontal:decrement, +*::-webkit-scrollbar-button:horizontal:increment { display: none; width: 0; height: 0; @@ -91,6 +100,13 @@ *::-webkit-scrollbar-corner { background: transparent; } +/* Firefox only: standardized scrollbar (naturally has no stepper buttons). */ +@supports (-moz-appearance: none) { + * { + scrollbar-width: thin; + scrollbar-color: var(--scrollbar-thumb) transparent; + } +} html, body { @@ -1923,6 +1939,23 @@ details.diag-item[open] > summary .diag-chevron { font-family: var(--font-mono, "SFMono-Regular", Consolas, "Liberation Mono", monospace); font-size: 11.5px; color: var(--text-color-muted, #59636e); + text-decoration: none; + border-radius: 6px; + transition: color 0.12s ease; +} +a.diag-ai-repo:hover { + color: var(--accent, var(--color-accent-fg, #0969da)); +} +a.diag-ai-repo:hover .diag-ai-repo-slug { + text-decoration: underline; + text-underline-offset: 2px; +} +a.diag-ai-repo:hover .diag-ai-repo-ico { + opacity: 1; +} +a.diag-ai-repo:focus-visible { + outline: 2px solid var(--accent, var(--color-focus-outline, #0969da)); + outline-offset: 2px; } .diag-ai-repo-ico { From 94d68cd87c3f68a711692a16c0e17c3cb3d5e80b Mon Sep 17 00:00:00 2001 From: David Pine <7679720+IEvangelist@users.noreply.github.com> Date: Wed, 1 Jul 2026 09:18:01 -0500 Subject: [PATCH 15/21] Add agent-readiness diagnostics section Add a new experimental "Agent readiness" section in the Diagnostics tab that probes emerging AI-agent standards (robots.txt, sitemap, Link headers, llms.txt, Markdown negotiation, AI-bot rules, Content Signals, MCP, A2A, Agent Skills, AI-plugin, DNS-AID, OAuth protected-resource/authorization-server, API Catalog) grouped into 5 categories, with per-check AI fix prompts and repo-detected Open-in-Copilot/Create-issue actions. Backed by a new server route /api/agent-readiness in lib/agent-readiness.mjs. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .github/extensions/og-preview/extension.mjs | 13 + .../og-preview/lib/agent-readiness.mjs | 318 ++++++++++++++ .github/extensions/og-preview/ui/app.js | 408 ++++++++++++++++-- .github/extensions/og-preview/ui/styles.css | 138 ++++++ 4 files changed, 833 insertions(+), 44 deletions(-) create mode 100644 .github/extensions/og-preview/lib/agent-readiness.mjs diff --git a/.github/extensions/og-preview/extension.mjs b/.github/extensions/og-preview/extension.mjs index 1f92d82ea..01083e909 100644 --- a/.github/extensions/og-preview/extension.mjs +++ b/.github/extensions/og-preview/extension.mjs @@ -17,6 +17,7 @@ import { joinSession, createCanvas, CanvasError } from "@github/copilot-sdk/exte import { fetchUrl, normalizeUrl } from "./lib/http-fetch.mjs"; import { parseMetadata } from "./lib/parse-og.mjs"; +import { checkAgentReadiness } from "./lib/agent-readiness.mjs"; const UI_DIR = new URL("./ui/", import.meta.url); @@ -480,6 +481,18 @@ async function handleRequest(entry, req, res) { } } + if (path === "/api/agent-readiness") { + const u = reqUrl.searchParams.get("u"); + if (!u) return sendJson(res, 400, { error: "Missing 'u' query parameter." }); + try { + const target = normalizeUrl(u); + const report = await checkAgentReadiness(target); + return sendJson(res, 200, report); + } catch (err) { + return sendJson(res, 200, { error: err.message }); + } + } + if (path === "/api/img") { const u = reqUrl.searchParams.get("u"); if (!u) { diff --git a/.github/extensions/og-preview/lib/agent-readiness.mjs b/.github/extensions/og-preview/lib/agent-readiness.mjs new file mode 100644 index 000000000..188a7e8e5 --- /dev/null +++ b/.github/extensions/og-preview/lib/agent-readiness.mjs @@ -0,0 +1,318 @@ +// Agent-readiness probe. Given a page URL, checks a curated set of emerging +// "is this site ready for AI agents" standards — discoverability (robots.txt, +// sitemap, Link headers), content-for-agents (llms.txt, Markdown negotiation), +// bot access control (AI crawler rules, Content Signals), protocol/agent +// discovery (MCP, A2A agent card, Agent Skills, AI plugin, DNS-AID) and auth +// (OAuth Protected Resource / Authorization Server, API Catalog). +// +// Everything is best-effort and read-only: we GET well-known paths and inspect +// response status/headers/bodies. Uncertain or bleeding-edge standards are +// reported as "emerging" (info) rather than a hard failure, so the section +// nudges without nagging. Inspired by the categories on isitagentready.com. + +import dns from "node:dns/promises"; +import { fetchUrl } from "./http-fetch.mjs"; + +// Known AI crawler / agent user-agents to look for in robots.txt. +const AI_BOTS = [ + "GPTBot", "OAI-SearchBot", "ChatGPT-User", "ClaudeBot", "Claude-Web", "Claude-User", + "anthropic-ai", "PerplexityBot", "Perplexity-User", "Google-Extended", "GoogleOther", + "Applebot-Extended", "CCBot", "Bytespider", "Amazonbot", "Meta-ExternalAgent", + "Meta-ExternalFetcher", "FacebookBot", "cohere-ai", "Diffbot", "ImagesiftBot", + "Omgilibot", "Omgili", "Timpibot", "YouBot", "DuckAssistBot", "PetalBot", "AI2Bot", + "MistralAI-User", "DeepSeek", "Scrapy", "Kangaroo", +]; + +function escapeRe(s) { + return String(s).replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); +} + +async function probe(url, opts = {}) { + try { + const r = await fetchUrl(url, { + timeoutMs: 7000, + maxRedirects: 4, + maxBytes: 256 * 1024, + ...opts, + }); + return { + ok: true, + status: r.status, + headers: r.headers || {}, + contentType: r.contentType || "", + body: r.body, + bytes: r.body ? r.body.length : 0, + finalUrl: r.url, + }; + } catch (err) { + return { ok: false, status: 0, error: String((err && err.message) || err) }; + } +} + +function bodyText(r, max = 4096) { + if (!r || !r.body) return ""; + return r.body.toString("utf8", 0, Math.min(r.body.length, max)); +} + +function looksLikeHtml(r) { + if (/text\/html/i.test(r.contentType || "")) return true; + const head = bodyText(r, 512).trimStart().toLowerCase(); + return head.startsWith("<!doctype html") || head.startsWith("<html") || head.includes("<head"); +} + +function isJsonish(r) { + if (r.status !== 200 || !r.bytes) return false; + if (/json/i.test(r.contentType || "")) return true; + const head = bodyText(r, 256).trimStart(); + return head.startsWith("{") || head.startsWith("["); +} + +function fmtBytes(n) { + if (!n) return "0 B"; + if (n < 1024) return `${n} B`; + return `${(n / 1024).toFixed(n < 10240 ? 1 : 0)} KB`; +} + +function missDetail(r) { + if (r && r.ok && r.status) return `Not found (HTTP ${r.status})`; + return "Not found"; +} + +async function dnsAid(host) { + const names = [`_agent.${host}`, `_aid.${host}`, `_aid-discovery.${host}`, host]; + for (const name of names) { + try { + const recs = await dns.resolveTxt(name); + const flat = recs + .map((chunks) => chunks.join("")) + .find((v) => /(^|;|\s)v=aid/i.test(v) || /\b(agent|endpoint)=/i.test(v)); + if (flat) return { ok: true, name, value: flat.slice(0, 240) }; + } catch { + /* NXDOMAIN / no TXT — keep trying the next candidate */ + } + } + return { ok: false }; +} + +export async function checkAgentReadiness(rawUrl) { + const u = new URL(rawUrl); + const origin = u.origin; + const host = u.hostname; + const W = (p) => origin + p; + + const [ + robots, sitemapXml, llms, llmsFull, mdNeg, page, + mcp1, mcp2, a2a1, a2a2, aiPlugin, skills1, skills2, + oauthPr, oauthAs, apiCatalog, aid, + ] = await Promise.all([ + probe(W("/robots.txt"), { accept: "text/plain,*/*;q=0.8" }), + probe(W("/sitemap.xml"), { accept: "application/xml,text/xml,*/*;q=0.8" }), + probe(W("/llms.txt"), { accept: "text/markdown,text/plain,*/*;q=0.8" }), + probe(W("/llms-full.txt"), { accept: "text/markdown,text/plain,*/*;q=0.8" }), + probe(rawUrl, { accept: "text/markdown; q=1.0, text/x-markdown; q=0.9, text/plain; q=0.5" }), + probe(rawUrl, { accept: "text/html,application/xhtml+xml" }), + probe(W("/.well-known/mcp"), { accept: "application/json,*/*;q=0.8" }), + probe(W("/.well-known/mcp.json"), { accept: "application/json,*/*;q=0.8" }), + probe(W("/.well-known/agent.json"), { accept: "application/json,*/*;q=0.8" }), + probe(W("/.well-known/agent-card.json"), { accept: "application/json,*/*;q=0.8" }), + probe(W("/.well-known/ai-plugin.json"), { accept: "application/json,*/*;q=0.8" }), + probe(W("/.well-known/agent-skills.json"), { accept: "application/json,*/*;q=0.8" }), + probe(W("/.well-known/skills.json"), { accept: "application/json,*/*;q=0.8" }), + probe(W("/.well-known/oauth-protected-resource"), { accept: "application/json,*/*;q=0.8" }), + probe(W("/.well-known/oauth-authorization-server"), { accept: "application/json,*/*;q=0.8" }), + probe(W("/.well-known/api-catalog"), { accept: "application/linkset+json,application/json,*/*;q=0.8" }), + dnsAid(host), + ]); + + // --- robots.txt driven signals --- + const robotsText = robots.ok && robots.status < 400 ? robots.body.toString("utf8") : ""; + const hasRobots = robots.ok && robots.status === 200 && robotsText.trim().length > 0; + const sitemapInRobots = /^\s*sitemap:\s*\S+/im.test(robotsText); + const sitemapFileOk = + sitemapXml.status === 200 && !looksLikeHtml(sitemapXml) && + /<(urlset|sitemapindex)\b/i.test(bodyText(sitemapXml, 2048)); + const hasSitemap = sitemapInRobots || sitemapFileOk; + const aiBotsFound = AI_BOTS.filter((b) => + new RegExp(`user-agent:\\s*${escapeRe(b)}\\b`, "i").test(robotsText), + ); + const contentSignals = /content-signal\s*:/i.test(robotsText) || /content-usage\s*:/i.test(robotsText); + + // --- content-for-agents --- + const hasLlms = llms.status === 200 && llms.bytes > 0 && !looksLikeHtml(llms); + const hasLlmsFull = llmsFull.status === 200 && llmsFull.bytes > 0 && !looksLikeHtml(llmsFull); + const mdOk = mdNeg.status === 200 && /text\/(x-)?markdown/i.test(mdNeg.contentType || ""); + + // --- Link headers (RFC 8288) on the main document --- + const linkHeader = page.ok ? page.headers.link || page.headers.Link : ""; + const linkRels = linkHeader + ? Array.from(String(linkHeader).matchAll(/rel="?([^",;]+)"?/gi)).map((m) => m[1].trim()) + : []; + const hasLink = linkRels.length > 0; + const apiCatalogLink = linkRels.some((r) => /api-catalog/i.test(r)); + + // --- protocol / agent discovery --- + const hasMcp = isJsonish(mcp1) || isJsonish(mcp2) || (mcp1.status === 200 && !looksLikeHtml(mcp1)); + const hasA2a = isJsonish(a2a1) || isJsonish(a2a2); + const hasAiPlugin = isJsonish(aiPlugin); + const hasSkills = isJsonish(skills1) || isJsonish(skills2); + const hasOauthPr = isJsonish(oauthPr); + const hasOauthAs = isJsonish(oauthAs); + const hasApiCatalog = apiCatalog.status === 200 && (isJsonish(apiCatalog) || apiCatalogLink); + + const scored = (ok, warnIfMissing) => (ok ? "pass" : warnIfMissing ? "warn" : "info"); + + const categories = [ + { + id: "discoverability", + label: "Discoverability", + checks: [ + { + id: "robots", + label: "robots.txt", + status: scored(hasRobots, true), + detail: hasRobots + ? `Found · ${fmtBytes(robots.bytes)}${sitemapInRobots ? " · declares Sitemap" : ""}` + : missDetail(robots), + }, + { + id: "sitemap", + label: "XML sitemap", + status: scored(hasSitemap, true), + detail: hasSitemap + ? sitemapInRobots + ? "Declared in robots.txt" + : "Found at /sitemap.xml" + : missDetail(sitemapXml), + }, + { + id: "link-headers", + label: "Response Link headers", + status: scored(hasLink, false), + detail: hasLink ? `Present · rel: ${linkRels.slice(0, 4).join(", ")}` : "No Link response header", + }, + ], + }, + { + id: "content", + label: "Content for agents", + checks: [ + { + id: "llms", + label: "llms.txt", + status: scored(hasLlms, true), + detail: hasLlms + ? `Found · ${fmtBytes(llms.bytes)}${hasLlmsFull ? " · llms-full.txt too" : ""}` + : missDetail(llms), + }, + { + id: "markdown", + label: "Markdown content negotiation", + status: scored(mdOk, true), + detail: mdOk + ? `Serves ${mdNeg.contentType.split(";")[0]} for Accept: text/markdown` + : "No Markdown returned for Accept: text/markdown", + }, + ], + }, + { + id: "bots", + label: "Bot access control", + checks: [ + { + id: "ai-bots", + label: "AI crawler rules", + status: scored(aiBotsFound.length > 0, true), + detail: aiBotsFound.length + ? `robots.txt names ${aiBotsFound.length} AI agent(s): ${aiBotsFound.slice(0, 4).join(", ")}${ + aiBotsFound.length > 4 ? "…" : "" + }` + : "No AI-specific user-agent rules in robots.txt", + }, + { + id: "content-signals", + label: "Content Signals", + status: scored(contentSignals, false), + detail: contentSignals ? "Content-Signal directives present" : "No Content-Signal policy in robots.txt", + }, + ], + }, + { + id: "protocols", + label: "Agent & protocol discovery", + checks: [ + { + id: "mcp", + label: "MCP server discovery", + status: scored(hasMcp, false), + detail: hasMcp ? "Found /.well-known/mcp" : missDetail(mcp2.status ? mcp2 : mcp1), + }, + { + id: "a2a", + label: "A2A agent card", + status: scored(hasA2a, false), + detail: hasA2a ? "Found /.well-known/agent(-card).json" : missDetail(a2a1), + }, + { + id: "agent-skills", + label: "Agent Skills manifest", + status: scored(hasSkills, false), + detail: hasSkills ? "Found a well-known skills manifest" : "No skills manifest", + }, + { + id: "ai-plugin", + label: "AI plugin manifest", + status: scored(hasAiPlugin, false), + detail: hasAiPlugin ? "Found /.well-known/ai-plugin.json" : missDetail(aiPlugin), + }, + { + id: "dns-aid", + label: "DNS for AI Discovery", + status: scored(aid.ok, false), + detail: aid.ok ? `TXT ${aid.name}` : "No agent-discovery TXT record", + }, + ], + }, + { + id: "auth", + label: "Auth for agents", + checks: [ + { + id: "oauth-pr", + label: "OAuth Protected Resource", + status: scored(hasOauthPr, false), + detail: hasOauthPr ? "Found (RFC 9728)" : missDetail(oauthPr), + }, + { + id: "oauth-as", + label: "OAuth Authorization Server", + status: scored(hasOauthAs, false), + detail: hasOauthAs ? "Found (RFC 8414)" : missDetail(oauthAs), + }, + { + id: "api-catalog", + label: "API Catalog", + status: scored(hasApiCatalog, false), + detail: hasApiCatalog ? "Found (RFC 9727)" : missDetail(apiCatalog), + }, + ], + }, + ]; + + let detected = 0; + let recommendedMissing = 0; + let total = 0; + for (const cat of categories) { + for (const c of cat.checks) { + total += 1; + if (c.status === "pass") detected += 1; + else if (c.status === "warn") recommendedMissing += 1; + } + } + + return { + url: rawUrl, + origin, + summary: { detected, recommendedMissing, total }, + categories, + }; +} diff --git a/.github/extensions/og-preview/ui/app.js b/.github/extensions/og-preview/ui/app.js index b9d8262e4..bf99e6071 100644 --- a/.github/extensions/og-preview/ui/app.js +++ b/.github/extensions/og-preview/ui/app.js @@ -61,6 +61,20 @@ const OCTICONS = { '<path fill="currentColor" d="M8 9.5a1.5 1.5 0 1 0 0-3 1.5 1.5 0 0 0 0 3Z"/><path fill="currentColor" d="M8 0a8 8 0 1 1 0 16A8 8 0 0 1 8 0ZM1.5 8a6.5 6.5 0 1 0 13 0 6.5 6.5 0 0 0-13 0Z"/>', copilot: '<path fill="currentColor" d="M7.998 15.035c-4.562 0-7.873-2.914-7.998-3.749V9.338c.085-.628.677-1.686 1.588-2.065.013-.07.024-.143.036-.218.029-.183.06-.384.126-.612-.201-.508-.254-1.084-.254-1.656 0-.87.128-1.769.693-2.484.579-.733 1.494-1.124 2.724-1.261 1.206-.134 2.262.034 2.944.765.05.053.096.108.139.165.044-.057.094-.112.143-.165.682-.731 1.738-.899 2.944-.765 1.23.137 2.145.528 2.724 1.261.566.715.693 1.614.693 2.484 0 .572-.053 1.148-.254 1.656.066.228.098.429.126.612.012.076.024.148.037.218.924.385 1.522 1.471 1.591 2.095v1.872c0 .766-3.351 3.795-8.002 3.795Zm0-1.485c2.28 0 4.584-1.11 5.002-1.433V7.862l-.023-.116c-.49.21-1.075.291-1.727.291-1.146 0-2.059-.327-2.71-.991A3.222 3.222 0 0 1 8 6.303a3.24 3.24 0 0 1-.544.743c-.65.664-1.563.991-2.71.991-.652 0-1.236-.081-1.727-.291l-.023.116v4.255c.419.323 2.722 1.433 5.002 1.433ZM6.762 2.83c-.193-.206-.637-.413-1.682-.297-1.019.113-1.479.404-1.713.7-.247.312-.369.789-.369 1.554 0 .793.129 1.171.308 1.371.162.181.519.379 1.442.379.853 0 1.339-.235 1.638-.54.315-.322.527-.827.617-1.553.117-.935-.037-1.395-.241-1.614Zm4.155-.297c-1.044-.116-1.488.091-1.681.297-.204.219-.359.679-.242 1.614.091.726.303 1.231.618 1.553.299.305.784.54 1.638.54.922 0 1.28-.198 1.442-.379.179-.2.308-.578.308-1.371 0-.765-.123-1.242-.37-1.554-.233-.296-.693-.587-1.713-.7Z"/><path fill="currentColor" d="M6.25 9.037a.75.75 0 0 1 .75.75v1.501a.75.75 0 0 1-1.5 0V9.787a.75.75 0 0 1 .75-.75Zm4.25.75v1.501a.75.75 0 0 1-1.5 0V9.787a.75.75 0 0 1 1.5 0Z"/>', + rocket: + '<path fill="currentColor" d="M14.064 0h.186C15.216 0 16 .784 16 1.75v.186a8.752 8.752 0 0 1-2.564 6.186l-.458.459c-.314.314-.641.616-.979.904v3.207c0 .608-.315 1.172-.833 1.49l-2.774 1.707a.749.749 0 0 1-1.11-.418l-.954-3.102a1.214 1.214 0 0 1-.145-.125L3.754 9.816a1.218 1.218 0 0 1-.124-.145L.528 8.717a.749.749 0 0 1-.418-1.11l1.71-2.774A1.748 1.748 0 0 1 3.31 4h3.204c.288-.338.59-.665.904-.979l.459-.458A8.749 8.749 0 0 1 14.064 0ZM8.938 3.623h-.002l-.458.458c-.76.76-1.437 1.598-2.02 2.5l-1.5 2.317 2.143 2.143 2.317-1.5c.902-.583 1.74-1.26 2.499-2.02l.459-.458a7.25 7.25 0 0 0 2.123-5.127V1.75a.25.25 0 0 0-.25-.25h-.186a7.249 7.249 0 0 0-5.125 2.123ZM3.56 14.56c-.732.732-2.334 1.045-3.005 1.148a.234.234 0 0 1-.201-.064.234.234 0 0 1-.064-.201c.103-.671.416-2.273 1.15-3.003a1.502 1.502 0 1 1 2.12 2.12Zm6.94-3.935c-.088.06-.177.118-.266.175l-2.35 1.521.548 1.783 1.949-1.2a.25.25 0 0 0 .119-.213ZM3.678 8.116 5.2 5.766c.058-.09.117-.178.176-.266H3.309a.25.25 0 0 0-.213.119l-1.2 1.95ZM12 5a1 1 0 1 1-2 0 1 1 0 0 1 2 0Z"/>', + telescope: + '<path fill="currentColor" d="M14.184 1.143v-.001l1.422 2.464a1.75 1.75 0 0 1-.757 2.451L3.104 11.713a1.75 1.75 0 0 1-2.275-.702l-.447-.775a1.75 1.75 0 0 1 .53-2.32L11.682.573a1.748 1.748 0 0 1 2.502.57Zm-4.709 9.32h-.001l2.644 3.863a.75.75 0 1 1-1.238.848l-1.881-2.75v2.826a.75.75 0 0 1-1.5 0v-2.826l-1.881 2.75a.75.75 0 1 1-1.238-.848l2.049-2.992a.746.746 0 0 1 .293-.253l1.809-.87a.749.749 0 0 1 .944.252ZM9.436 3.92h-.001l-4.97 3.39.942 1.63 5.42-2.61Zm3.091-2.108h.001l-1.85 1.26 1.505 2.605 2.016-.97a.247.247 0 0 0 .13-.151.247.247 0 0 0-.022-.199l-1.422-2.464a.253.253 0 0 0-.161-.119.254.254 0 0 0-.197.038ZM1.756 9.157a.25.25 0 0 0-.075.33l.447.775a.25.25 0 0 0 .325.1l1.598-.769-.83-1.436-1.465 1Z"/>', + book: + '<path fill="currentColor" d="M0 1.75A.75.75 0 0 1 .75 1h4.253c1.227 0 2.317.59 3 1.501A3.743 3.743 0 0 1 11.006 1h4.245a.75.75 0 0 1 .75.75v10.5a.75.75 0 0 1-.75.75h-4.507a2.25 2.25 0 0 0-1.591.659l-.622.621a.75.75 0 0 1-1.06 0l-.622-.621A2.25 2.25 0 0 0 5.258 13H.75a.75.75 0 0 1-.75-.75Zm7.251 10.324.004-5.073-.002-2.253A2.25 2.25 0 0 0 5.003 2.5H1.5v9h3.757a3.75 3.75 0 0 1 1.994.574ZM8.755 4.75l-.004 7.322a3.752 3.752 0 0 1 1.992-.572H14.5v-9h-3.495a2.25 2.25 0 0 0-2.25 2.25Z"/>', + law: + '<path fill="currentColor" d="M8.75.75V2h.985c.304 0 .603.08.867.231l1.29.736c.038.022.08.033.124.033h2.234a.75.75 0 0 1 0 1.5h-.427l2.111 4.692a.75.75 0 0 1-.154.838l-.53-.53.529.531-.001.002-.002.002-.006.006-.006.005-.01.01-.045.04c-.21.176-.441.327-.686.45C14.556 10.78 13.88 11 13 11a4.498 4.498 0 0 1-2.023-.454 3.544 3.544 0 0 1-.686-.45l-.045-.04-.016-.015-.006-.006-.004-.004v-.001a.75.75 0 0 1-.154-.838L12.178 4.5h-.162c-.305 0-.604-.079-.868-.231l-1.29-.736a.245.245 0 0 0-.124-.033H8.75V13h2.5a.75.75 0 0 1 0 1.5h-6.5a.75.75 0 0 1 0-1.5h2.5V3.5h-.984a.245.245 0 0 0-.124.033l-1.289.737c-.265.15-.564.23-.869.23h-.162l2.112 4.692a.75.75 0 0 1-.154.838l-.53-.53.529.531-.001.002-.002.002-.006.006-.016.015-.045.04c-.21.176-.441.327-.686.45C4.556 10.78 3.88 11 3 11a4.498 4.498 0 0 1-2.023-.454 3.544 3.544 0 0 1-.686-.45l-.045-.04-.016-.015-.006-.006-.004-.004v-.001a.75.75 0 0 1-.154-.838L2.178 4.5H1.75a.75.75 0 0 1 0-1.5h2.234a.249.249 0 0 0 .125-.033l1.288-.737c.265-.15.564-.23.869-.23h.984V.75a.75.75 0 0 1 1.5 0Zm2.945 8.477c.285.135.718.273 1.305.273s1.02-.138 1.305-.273L13 6.327Zm-10 0c.285.135.718.273 1.305.273s1.02-.138 1.305-.273L3 6.327Z"/>', + plug: + '<path fill="currentColor" d="M4 8H2.5a1 1 0 0 0-1 1v5.25a.75.75 0 0 1-1.5 0V9a2.5 2.5 0 0 1 2.5-2.5H4V5.133a1.75 1.75 0 0 1 1.533-1.737l2.831-.353.76-.913c.332-.4.825-.63 1.344-.63h.782c.966 0 1.75.784 1.75 1.75V4h2.25a.75.75 0 0 1 0 1.5H13v4h2.25a.75.75 0 0 1 0 1.5H13v.75a1.75 1.75 0 0 1-1.75 1.75h-.782c-.519 0-1.012-.23-1.344-.63l-.761-.912-2.83-.354A1.75 1.75 0 0 1 4 9.867Zm6.276-4.91-.95 1.14a.753.753 0 0 1-.483.265l-3.124.39a.25.25 0 0 0-.219.248v4.734c0 .126.094.233.219.249l3.124.39a.752.752 0 0 1 .483.264l.95 1.14a.25.25 0 0 0 .192.09h.782a.25.25 0 0 0 .25-.25v-8.5a.25.25 0 0 0-.25-.25h-.782a.25.25 0 0 0-.192.09Z"/>', + key: + '<path fill="currentColor" d="M10.5 0a5.499 5.499 0 1 1-1.288 10.848l-.932.932a.749.749 0 0 1-.53.22H7v.75a.749.749 0 0 1-.22.53l-.5.5a.749.749 0 0 1-.53.22H5v.75a.749.749 0 0 1-.22.53l-.5.5a.749.749 0 0 1-.53.22h-2A1.75 1.75 0 0 1 0 14.25v-2c0-.199.079-.389.22-.53l4.932-4.932A5.5 5.5 0 0 1 10.5 0Zm-4 5.5c-.001.431.069.86.205 1.269a.75.75 0 0 1-.181.768L1.5 12.56v1.69c0 .138.112.25.25.25h1.69l.06-.06v-1.19a.75.75 0 0 1 .75-.75h1.19l.06-.06v-1.19a.75.75 0 0 1 .75-.75h1.19l1.023-1.025a.75.75 0 0 1 .768-.18A4 4 0 1 0 6.5 5.5ZM11 6a1 1 0 1 1 0-2 1 1 0 0 1 0 2Z"/>', + info: + '<path fill="currentColor" d="M0 8a8 8 0 1 1 16 0A8 8 0 0 1 0 8Zm8-6.5a6.5 6.5 0 1 0 0 13 6.5 6.5 0 0 0 0-13ZM6.5 7.75A.75.75 0 0 1 7.25 7h1a.75.75 0 0 1 .75.75v2.75h.25a.75.75 0 0 1 0 1.5h-2a.75.75 0 0 1 0-1.5h.25v-2h-.25a.75.75 0 0 1-.75-.75ZM8 6a1 1 0 1 1 0-2 1 1 0 0 1 0 2Z"/>', }; function octicon(name, size, cls) { @@ -1447,8 +1461,34 @@ function buildAiFixPrompt(check, help, url) { return lines.join("\n"); } -function diagAiPrompt(check, help, url, repo) { - const prompt = buildAiFixPrompt(check, help, url); +// Prompt for the agent-readiness checks: describe the emerging standard and ask +// the coding agent to add the corresponding file/config to the site's repo. +function buildAgentFixPrompt(check, help, url) { + const lines = []; + lines.push("Make my website more agent-ready (AI-agent / crawler friendly)."); + lines.push(""); + lines.push(`Site: ${url || "(unknown)"}`); + lines.push(`Standard: ${check.label || check.id}${check.level ? ` (${check.level})` : ""}`); + if (check.detail) lines.push(`Current status: ${check.detail}`); + if (help && help.why) lines.push(`Why it matters: ${help.why}`); + if (help && help.fix) lines.push(`Goal: ${help.fix}`); + if (help && help.example) { + lines.push(""); + lines.push("Reference:"); + lines.push(help.example); + } + lines.push(""); + lines.push( + "Give me the exact file(s) or server config to add to implement this on my site — " + + "include the correct path (e.g. /.well-known/…, /robots.txt, /llms.txt) and the full " + + "file contents, using real values inferred from the site above. If you can infer the " + + "framework or host (Astro, Next.js, Hugo, Cloudflare, static hosting, …), show exactly " + + "where the file/route goes; otherwise give a host-agnostic version. Keep it concise.", + ); + return lines.join("\n"); +} + +function aiPromptHead(prompt, copyLabel) { const head = el("div", { class: "diag-ai-head" }); const ico = el("span", { class: "diag-ai-ico", "aria-hidden": "true" }); ico.innerHTML = @@ -1456,18 +1496,34 @@ function diagAiPrompt(check, help, url, repo) { head.append( ico, el("span", { class: "diag-ai-title", text: "AI fix prompt" }), - copyButton(prompt, "Copy AI prompt"), + copyButton(prompt, copyLabel || "Copy AI prompt"), ); - const children = [head, el("pre", { class: "diag-ai-body", text: prompt })]; + return head; +} + +function diagAiPrompt(check, help, url, repo) { + const prompt = buildAiFixPrompt(check, help, url); + const children = [aiPromptHead(prompt), el("pre", { class: "diag-ai-body", text: prompt })]; children.push(diagAiFooter(check, prompt, url, repo)); return el("div", { class: "diag-ai" }, children); } +// Agent-readiness variant: same layout, but the prompt asks the agent to add the +// missing agent-readiness file/config and the footer action title reflects that. +function agentAiPrompt(check, help, url, repo) { + const prompt = buildAgentFixPrompt(check, help, url); + const children = [aiPromptHead(prompt), el("pre", { class: "diag-ai-body", text: prompt })]; + children.push( + diagAiFooter(check, prompt, url, repo, { title: `Improve agent readiness: ${check.label || check.id}` }), + ); + return el("div", { class: "diag-ai" }, children); +} + // Footer under the AI prompt. When a source repo was detected in the page, it // offers two Copilot actions: open a coding session seeded with the prompt, or // file an issue and hand it to the Copilot agent. Both round-trip through the // extension's loopback API, which asks the host chat session to do the work. -function diagAiFooter(check, prompt, url, repo) { +function diagAiFooter(check, prompt, url, repo, opts) { const foot = el("div", { class: "diag-ai-foot" }); if (!repo || !repo.owner || !repo.repo) { foot.classList.add("diag-ai-foot-empty"); @@ -1480,7 +1536,7 @@ function diagAiFooter(check, prompt, url, repo) { return foot; } const slug = `${repo.owner}/${repo.repo}`; - const title = `Fix OpenGraph metadata: ${check.id}`; + const title = (opts && opts.title) || `Fix OpenGraph metadata: ${check.id}`; const repoTag = el("a", { class: "diag-ai-repo", href: repo.url || `https://github.com/${slug}`, @@ -1606,49 +1662,313 @@ function diagnosticCounts(diagnostics) { return counts; } +function ogDiagItem(c, data) { + const kind = c.ok ? "ok" : c.level === "required" ? "req" : "warn"; + const levelText = c.ok ? "passed" : c.level; + + // Passing checks render as plain, non-expandable rows. + if (c.ok) { + return el("div", { class: "diag-item" }, [ + el("div", { class: "diag-row" }, [ + diagIcon(kind), + el("div", { class: "diag-text" }, [ + el("span", { class: "diag-id", text: c.id }), + el("span", { class: `diag-level ${kind}`, text: levelText }), + c.note ? el("div", { class: "diag-note", text: c.note }) : null, + ]), + ]), + ]); + } + + // Failing checks expand to show what's wrong and how to fix it. + const help = + DIAG_HELP[c.id] || { + why: c.note || "This recommended metadata is missing or invalid.", + fix: "Add or correct this metadata tag in the document <head>.", + }; + const details = el("details", { class: `diag-item diag-${kind}` }); + details.appendChild( + el("summary", { class: "diag-row" }, [ + diagIcon(kind), + el("div", { class: "diag-text" }, [ + el("span", { class: "diag-id", text: c.id }), + el("span", { class: `diag-level ${kind}`, text: levelText }), + c.note ? el("div", { class: "diag-note", text: c.note }) : null, + ]), + diagChevron(), + ]), + ); + details.appendChild(diagDetail(help, { check: c, url: data.requestedUrl, repo: data.repository })); + return details; +} + +function diagSectionHead(iconName, title, badge) { + return el("div", { class: "diag-sec-head" }, [ + octicon(iconName, 15, "diag-sec-ico"), + el("span", { class: "diag-sec-title", text: title }), + badge ? el("span", { class: "diag-sec-badge", text: badge }) : null, + ]); +} + function renderDiagnostics(data) { const host = $("#diagnostics"); - host.replaceChildren( - ...data.diagnostics.map((c) => { - const kind = c.ok ? "ok" : c.level === "required" ? "req" : "warn"; - const levelText = c.ok ? "passed" : c.level; - - // Passing checks render as plain, non-expandable rows. - if (c.ok) { - return el("div", { class: "diag-item" }, [ - el("div", { class: "diag-row" }, [ - diagIcon(kind), - el("div", { class: "diag-text" }, [ - el("span", { class: "diag-id", text: c.id }), - el("span", { class: `diag-level ${kind}`, text: levelText }), - c.note ? el("div", { class: "diag-note", text: c.note }) : null, - ]), - ]), - ]); - } + const ogSection = el("div", { class: "diag-section" }, [ + diagSectionHead("checklist", "Social & OpenGraph metadata"), + el("div", { class: "diag-list" }, data.diagnostics.map((c) => ogDiagItem(c, data))), + ]); + const arSection = el("div", { class: "diag-section" }, [ + diagSectionHead("rocket", "Agent readiness", "experimental"), + el("div", { class: "ar-intro" }, [ + document.createTextNode( + "Emerging standards that help AI agents discover, read, and act on this site. ", + ), + el("a", { + class: "ar-intro-link", + href: "https://isitagentready.com/", + target: "_blank", + rel: "noreferrer", + text: "Learn more", + }), + ]), + el("div", { id: "ar-body", class: "ar-body" }, [arLoading()]), + ]); + host.replaceChildren(ogSection, arSection); + refreshAgentReadiness(data); +} - // Failing checks expand to show what's wrong and how to fix it. - const help = - DIAG_HELP[c.id] || { - why: c.note || "This recommended metadata is missing or invalid.", - fix: "Add or correct this metadata tag in the document <head>.", - }; - const details = el("details", { class: `diag-item diag-${kind}` }); - details.appendChild( - el("summary", { class: "diag-row" }, [ - diagIcon(kind), - el("div", { class: "diag-text" }, [ - el("span", { class: "diag-id", text: c.id }), - el("span", { class: `diag-level ${kind}`, text: levelText }), - c.note ? el("div", { class: "diag-note", text: c.note }) : null, - ]), - diagChevron(), - ]), - ); - details.appendChild(diagDetail(help, { check: c, url: data.requestedUrl, repo: data.repository })); - return details; +/* ---------------- Agent readiness ---------------- */ + +const AR_CAT_ICON = { + discoverability: "telescope", + content: "book", + bots: "law", + protocols: "plug", + auth: "key", +}; + +const OGP_AR = "https://isitagentready.com/"; + +// Client-side guidance for each agent-readiness check id (why / fix / example / +// docs). Kept here so the server probe stays a pure prober. +const AGENT_HELP = { + robots: { + why: "robots.txt is the first file agents and crawlers look for. It advertises what they may fetch and where to find your sitemap.", + fix: "Publish /robots.txt at the site root with at least a default User-agent rule and a Sitemap directive.", + example: "User-agent: *\nAllow: /\nSitemap: https://example.com/sitemap.xml", + docs: { href: "https://www.rfc-editor.org/rfc/rfc9309.html", label: "RFC 9309 — Robots Exclusion Protocol" }, + }, + sitemap: { + why: "An XML sitemap lets agents enumerate your pages instead of guessing links, so they index content completely and efficiently.", + fix: "Publish a sitemap.xml (or a sitemap index) and reference it from robots.txt with a Sitemap: line.", + example: '<?xml version="1.0" encoding="UTF-8"?>\n<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">\n <url><loc>https://example.com/</loc></url>\n</urlset>', + docs: { href: "https://www.sitemaps.org/protocol.html", label: "sitemaps.org protocol" }, + }, + "link-headers": { + why: "Link response headers (RFC 8288) let agents discover related resources — canonical URLs, Markdown alternates, API catalogs — without parsing HTML.", + fix: 'Emit a Link header on key responses, e.g. an alternate Markdown representation or rel="api-catalog".', + example: 'Link: </index.md>; rel="alternate"; type="text/markdown"', + docs: { href: "https://www.rfc-editor.org/rfc/rfc8288.html", label: "RFC 8288 — Web Linking" }, + }, + llms: { + why: "llms.txt is an emerging convention that gives LLMs a curated, Markdown map of your most important content and docs.", + fix: "Add /llms.txt (and optionally /llms-full.txt) at the site root: a short H1, a blockquote summary, then curated Markdown links.", + example: "# Example\n> One-line summary of the site.\n\n## Docs\n- [Getting started](https://example.com/start): quick intro", + docs: { href: "https://llmstxt.org/", label: "llmstxt.org" }, + }, + markdown: { + why: "Serving a Markdown representation when an agent sends Accept: text/markdown gives clean, token-efficient content instead of noisy HTML.", + fix: "Content-negotiate text/markdown for your pages (many frameworks/CDNs support a .md alternate or Cloudflare's Markdown-for-agents).", + example: "GET /page Accept: text/markdown → 200 Content-Type: text/markdown", + docs: { + href: "https://developers.cloudflare.com/fundamentals/reference/markdown-for-agents/", + label: "Cloudflare — Markdown for agents", + }, + }, + "ai-bots": { + why: "Explicit rules for AI user-agents (GPTBot, ClaudeBot, Google-Extended, PerplexityBot, …) let you allow or disallow AI training and answering on your terms.", + fix: "Add per-agent User-agent groups in robots.txt for the AI crawlers you want to allow or block.", + example: "User-agent: GPTBot\nAllow: /\n\nUser-agent: CCBot\nDisallow: /", + docs: { href: "https://platform.openai.com/docs/bots", label: "AI crawler user-agents" }, + }, + "content-signals": { + why: "Cloudflare Content Signals express how your content may be used (search, AI input, AI training) as a machine-readable policy in robots.txt.", + fix: "Add a Content-Signal policy block to robots.txt declaring your search / ai-input / ai-train preferences.", + example: "User-agent: *\nContent-Signal: search=yes, ai-train=no\nAllow: /", + docs: { href: "https://blog.cloudflare.com/content-signals/", label: "Cloudflare — Content Signals" }, + }, + mcp: { + why: "A Model Context Protocol endpoint lets agents call your site's tools/resources directly instead of scraping.", + fix: "Expose an MCP server and advertise it (e.g. at /.well-known/mcp) so agents can discover and connect to it.", + example: '{ "mcpServers": { "example": { "url": "https://example.com/mcp" } } }', + docs: { href: "https://modelcontextprotocol.io/", label: "modelcontextprotocol.io" }, + }, + a2a: { + why: "An A2A Agent Card describes an agent's identity, skills, and endpoint so other agents can discover and delegate to it.", + fix: "Publish an Agent Card JSON at /.well-known/agent-card.json (or /.well-known/agent.json).", + example: '{ "name": "Example Agent", "url": "https://example.com/a2a", "skills": [] }', + docs: { href: "https://a2a-protocol.org/", label: "Agent2Agent (A2A) protocol" }, + }, + "agent-skills": { + why: "A published skills manifest lets agents load reusable, portable skills your site or product exposes.", + fix: "Publish a skills manifest at a well-known path describing each skill and how to invoke it.", + example: '{ "skills": [ { "name": "example", "path": "/skills/example" } ] }', + docs: { href: "https://agentskills.io/", label: "agentskills.io" }, + }, + "ai-plugin": { + why: "An AI plugin manifest (ai-plugin.json) is a legacy but still-recognized way to describe an API for AI tools to call.", + fix: "Publish /.well-known/ai-plugin.json pointing to your OpenAPI spec and describing the plugin.", + example: '{ "schema_version": "v1", "name_for_model": "example", "api": { "url": "https://example.com/openapi.json" } }', + docs: { href: "https://platform.openai.com/docs/plugins/getting-started", label: "AI plugin manifest" }, + }, + "dns-aid": { + why: "DNS for AI Discovery (DNS-AID) advertises an agent endpoint via a DNS TXT record, so agents can find you before ever fetching a page.", + fix: "Add a TXT record (e.g. at _agent.<domain>) describing your agent endpoint and version.", + example: '_agent.example.com TXT "v=aid1; endpoint=https://example.com/agent"', + docs: { href: "https://datatracker.ietf.org/wg/spawn/about/", label: "IETF SPAWN — agent discovery" }, + }, + "oauth-pr": { + why: "An OAuth Protected Resource document tells agents which authorization server guards your API and what scopes it needs.", + fix: "Publish /.well-known/oauth-protected-resource per RFC 9728 pointing at your authorization server.", + example: '{ "resource": "https://api.example.com", "authorization_servers": ["https://auth.example.com"] }', + docs: { href: "https://www.rfc-editor.org/rfc/rfc9728.html", label: "RFC 9728" }, + }, + "oauth-as": { + why: "OAuth Authorization Server metadata lets agents dynamically discover token, authorization, and registration endpoints.", + fix: "Publish /.well-known/oauth-authorization-server per RFC 8414.", + example: '{ "issuer": "https://auth.example.com", "token_endpoint": "https://auth.example.com/token" }', + docs: { href: "https://www.rfc-editor.org/rfc/rfc8414.html", label: "RFC 8414" }, + }, + "api-catalog": { + why: "An API Catalog lists the APIs you offer and links to their descriptions, so agents can find machine-usable endpoints.", + fix: "Publish /.well-known/api-catalog (a linkset) per RFC 9727 listing your API description documents.", + example: '{ "linkset": [ { "anchor": "https://api.example.com", "service-desc": [ { "href": "https://api.example.com/openapi.json" } ] } ] }', + docs: { href: "https://www.rfc-editor.org/rfc/rfc9727.html", label: "RFC 9727" }, + }, +}; + +function arLoading() { + return el("div", { class: "ar-loading" }, [ + el("span", { class: "ar-spinner", "aria-hidden": "true" }), + el("span", { text: "Probing agent-readiness signals…" }), + ]); +} + +function arIcon(status) { + if (status === "info") { + const s = el("span", { class: "diag-ico info", "aria-hidden": "true" }); + s.innerHTML = `<svg width="16" height="16" viewBox="0 0 16 16">${OCTICONS.info}</svg>`; + return s; + } + return diagIcon(status === "warn" ? "warn" : "ok"); +} + +function arLevelText(status) { + return status === "pass" ? "detected" : status === "warn" ? "recommended" : "emerging"; +} + +function arRow(c, ar, repo) { + const kind = c.status === "pass" ? "ok" : c.status === "warn" ? "warn" : "info"; + const textCol = () => + el("div", { class: "diag-text" }, [ + el("span", { class: "diag-id", text: c.label }), + el("span", { class: `diag-level ${kind}`, text: arLevelText(c.status) }), + c.detail ? el("div", { class: "diag-note", text: c.detail }) : null, + ]); + + // Detected signals render as plain, non-expandable rows. + if (c.status === "pass") { + return el("div", { class: "diag-item" }, [el("div", { class: "diag-row" }, [arIcon(c.status), textCol()])]); + } + + // Missing / not-yet-detected signals expand to guidance + an AI fix prompt. + const help = AGENT_HELP[c.id] || { why: c.detail || "", fix: "Add support for this standard to your site." }; + const details = el("details", { class: `diag-item diag-${kind}` }); + details.appendChild(el("summary", { class: "diag-row" }, [arIcon(c.status), textCol(), diagChevron()])); + details.appendChild( + arDetail(help, { + check: { id: c.id, label: c.label, detail: c.detail, level: c.status === "warn" ? "recommended" : "emerging" }, + url: ar.url, + repo, }), ); + return details; +} + +function arDetail(help, ctx) { + const detail = el("div", { class: "diag-detail" }); + detail.appendChild( + el("div", { class: "diag-block" }, [ + el("div", { class: "diag-block-h", text: "Why it matters" }), + el("p", { class: "diag-block-p", text: help.why }), + ]), + ); + detail.appendChild( + el("div", { class: "diag-block" }, [ + el("div", { class: "diag-block-h", text: "How to add it" }), + el("p", { class: "diag-block-p", text: help.fix }), + ]), + ); + if (help.example) { + detail.appendChild( + el("div", { class: "diag-snippet" }, [ + el("code", { text: help.example }), + copyButton(help.example, "Copy snippet"), + ]), + ); + } + detail.appendChild(agentAiPrompt(ctx.check, help, ctx.url, ctx.repo)); + if (help.docs) { + const link = el("a", { class: "diag-docs", href: help.docs.href, target: "_blank", rel: "noreferrer" }); + link.append(octicon("link-external", 14, "diag-docs-ico"), document.createTextNode(help.docs.label)); + detail.appendChild(link); + } + return detail; +} + +function renderAgentReadiness(body, ar, repo) { + const s = ar.summary || { detected: 0, recommendedMissing: 0, total: 0 }; + const summary = el("div", { class: "ar-summary" }, [ + el("span", { class: "pill ok", text: `${s.detected} detected` }), + s.recommendedMissing ? el("span", { class: "pill warn", text: `${s.recommendedMissing} recommended missing` }) : null, + el("span", { class: "ar-summary-total", text: `of ${s.total} checks` }), + ]); + const groups = (ar.categories || []).map((cat) => + el("div", { class: "ar-group" }, [ + el("div", { class: "ar-group-h" }, [ + octicon(AR_CAT_ICON[cat.id] || "info", 13, "ar-group-ico"), + el("span", { text: cat.label }), + ]), + el("div", { class: "diag-list" }, cat.checks.map((c) => arRow(c, ar, repo))), + ]), + ); + body.replaceChildren(summary, ...groups); +} + +let arSeq = 0; + +async function refreshAgentReadiness(data) { + const body = $("#ar-body"); + if (!body) return; + const seq = ++arSeq; + const targetUrl = data.requestedUrl; + if (!targetUrl) { + body.replaceChildren(el("div", { class: "ar-error", text: "No URL to analyze." })); + return; + } + try { + const res = await fetch("/api/agent-readiness?u=" + encodeURIComponent(targetUrl)); + const ar = await res.json(); + if (seq !== arSeq) return; // a newer load superseded this probe + if (!res.ok || ar.error) throw new Error(ar.error || `Request failed (${res.status})`); + renderAgentReadiness(body, ar, data.repository); + } catch (err) { + if (seq !== arSeq) return; + body.replaceChildren( + el("div", { class: "ar-error" }, [ + el("span", { text: `Couldn't run agent-readiness checks: ${err.message}` }), + ]), + ); + } } /* ---------------- Footer (page info) ---------------- */ diff --git a/.github/extensions/og-preview/ui/styles.css b/.github/extensions/og-preview/ui/styles.css index 3500450e4..5fa6b5c04 100644 --- a/.github/extensions/og-preview/ui/styles.css +++ b/.github/extensions/og-preview/ui/styles.css @@ -1709,6 +1709,43 @@ body[data-mode="dark"] .cl-t .hl-code { color: var(--color-prettylights-syntax-s /* ---------- Diagnostics ---------- */ #diagnostics { + display: flex; + flex-direction: column; + gap: 18px; +} + +.diag-section { + display: flex; + flex-direction: column; + gap: 9px; +} + +.diag-sec-head { + display: flex; + align-items: center; + gap: 8px; +} +.diag-sec-ico { + color: var(--text-color-muted, #59636e); + flex: 0 0 auto; +} +.diag-sec-title { + font-size: 13px; + font-weight: var(--font-weight-semibold, 600); + color: var(--text-color-default, #1f2328); +} +.diag-sec-badge { + font-size: 10px; + letter-spacing: 0.03em; + text-transform: uppercase; + padding: 2px 7px; + border-radius: 999px; + color: var(--accent, #0969da); + border: 1px solid color-mix(in srgb, var(--accent, #0969da) 35%, transparent); + background: color-mix(in srgb, var(--accent, #0969da) 8%, transparent); +} + +.diag-list { border: 1px solid var(--border-soft); border-radius: var(--radius-lg); overflow: hidden; @@ -1767,6 +1804,10 @@ summary.diag-row:focus-visible { .diag-ico.req { color: var(--true-color-red, #cf222e); } +.diag-ico.info { + color: var(--text-color-muted, #59636e); + opacity: 0.85; +} .diag-text { flex: 1 1 auto; @@ -1799,6 +1840,10 @@ summary.diag-row:focus-visible { color: var(--true-color-red, #cf222e); border-color: color-mix(in srgb, var(--true-color-red, #cf222e) 40%, transparent); } +.diag-level.info { + color: var(--text-color-muted, #59636e); + border-color: var(--border-soft); +} .diag-note { color: var(--text-color-muted, #59636e); @@ -2032,6 +2077,99 @@ a.diag-ai-repo:focus-visible { color: inherit; } +/* ---------- Agent readiness ---------- */ +.ar-intro { + font-size: 13px; + color: var(--text-color-muted, #59636e); + margin: -2px 0 2px; + line-height: 1.5; +} +.ar-intro-link { + color: var(--accent); + text-decoration: none; + font-weight: var(--font-weight-semibold, 600); +} +.ar-intro-link:hover { + text-decoration: underline; +} + +.ar-body { + display: flex; + flex-direction: column; + gap: 14px; +} + +.ar-loading { + display: flex; + align-items: center; + gap: 9px; + padding: 14px; + font-size: 13px; + color: var(--text-color-muted, #59636e); + border: 1px solid var(--border-soft); + border-radius: var(--radius-lg); + background: var(--surface-raised); +} +.ar-spinner { + flex: 0 0 auto; + width: 14px; + height: 14px; + border-radius: 50%; + border: 2px solid color-mix(in srgb, var(--text-color-muted, #59636e) 30%, transparent); + border-top-color: var(--accent, #0969da); + animation: ar-spin 0.7s linear infinite; +} +@keyframes ar-spin { + to { + transform: rotate(360deg); + } +} +@media (prefers-reduced-motion: reduce) { + .ar-spinner { + animation-duration: 1.6s; + } +} + +.ar-error { + padding: 12px 14px; + font-size: 13px; + color: var(--true-color-orange, #bc4c00); + border: 1px solid color-mix(in srgb, var(--true-color-orange, #bc4c00) 30%, transparent); + border-radius: var(--radius-lg); + background: color-mix(in srgb, var(--true-color-orange, #bc4c00) 6%, transparent); +} + +.ar-summary { + display: flex; + align-items: center; + flex-wrap: wrap; + gap: 8px; +} +.ar-summary-total { + font-size: 12px; + color: var(--text-color-muted, #59636e); +} + +.ar-group { + display: flex; + flex-direction: column; + gap: 7px; +} +.ar-group-h { + display: flex; + align-items: center; + gap: 7px; + font-size: 11px; + font-weight: var(--font-weight-semibold, 600); + letter-spacing: 0.02em; + text-transform: uppercase; + color: var(--text-color-muted, #59636e); +} +.ar-group-ico { + color: var(--text-color-muted, #59636e); + flex: 0 0 auto; +} + /* ---------- Collapsible footer (page info) ---------- */ .footer { flex: 0 0 auto; From 6e4b1f8c87dd753f8117096a73759f48a3a74e76 Mon Sep 17 00:00:00 2001 From: David Pine <7679720+IEvangelist@users.noreply.github.com> Date: Wed, 1 Jul 2026 09:52:50 -0500 Subject: [PATCH 16/21] Add OpenGraph structured-property diagnostics Extend the OG diagnostics with ogp.me optional/structured checks: og:locale (optional), and, when og:image is present, og:image:alt (recommended), og:image dimensions = width+height (recommended), og:image:type (optional), and og:image:secure_url (optional). Image sub-checks are only emitted when an og:image exists. Adds matching DIAG_HELP guidance (why/fix/example/docs -> ogp.me#structured) so each new check expands with an AI fix prompt. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .../extensions/og-preview/lib/parse-og.mjs | 49 ++++++++++++++++++- .github/extensions/og-preview/ui/app.js | 30 ++++++++++++ 2 files changed, 78 insertions(+), 1 deletion(-) diff --git a/.github/extensions/og-preview/lib/parse-og.mjs b/.github/extensions/og-preview/lib/parse-og.mjs index ff080d17b..c735e462c 100644 --- a/.github/extensions/og-preview/lib/parse-og.mjs +++ b/.github/extensions/og-preview/lib/parse-og.mjs @@ -208,6 +208,14 @@ export function parseMetadata(html, baseUrl) { else groups.other.push(item); } + const hasImage = !!ogImageRaw; + const imgAlt = first("og:image:alt"); + const imgWidth = first("og:image:width"); + const imgHeight = first("og:image:height"); + const imgType = first("og:image:type"); + const imgSecure = first("og:image:secure_url"); + const isHttpsImage = /^https:\/\//i.test(image); + const diagnostics = [ check("og:title", !!first("og:title"), "required", "Primary title shown in shares."), check("og:type", !!first("og:type"), "recommended", "e.g. website, article, video."), @@ -215,6 +223,7 @@ export function parseMetadata(html, baseUrl) { check("og:url", !!first("og:url"), "recommended", "Canonical URL of the object."), check("og:description", !!first("og:description"), "recommended", "Short summary (<200 chars)."), check("og:site_name", !!resolved.siteName, "optional", "Human-readable site name."), + check("og:locale", !!resolved.locale, "optional", "Content locale, e.g. en_US (defaults to en_US)."), check("twitter:card", !!resolved.twitterCard, "recommended", "Controls X/Twitter card layout."), check( "Absolute og:image URL", @@ -222,13 +231,51 @@ export function parseMetadata(html, baseUrl) { "recommended", "Crawlers require absolute image URLs.", ), + ]; + + // Structured og:image sub-properties only matter when an og:image exists — + // otherwise the required og:image failure already covers it. + if (hasImage) { + diagnostics.push( + check( + "og:image:alt", + !!imgAlt, + "recommended", + "Alt text describing the image — ogp.me recommends it whenever og:image is set.", + ), + check( + "og:image dimensions", + !!imgWidth && !!imgHeight, + "recommended", + imgWidth && imgHeight + ? `Declared ${imgWidth}×${imgHeight} — lets platforms render the card before fetching the image.` + : "Add og:image:width and og:image:height so platforms size the card instantly.", + ), + check( + "og:image:type", + !!imgType, + "optional", + imgType ? `MIME type ${imgType}.` : "MIME type of the image, e.g. image/png.", + ), + check( + "og:image:secure_url", + isHttpsImage || !!imgSecure, + "optional", + isHttpsImage + ? "Preview image is served over HTTPS." + : "og:image is HTTP — add an HTTPS og:image:secure_url.", + ), + ); + } + + diagnostics.push( check( "Description length OK", !resolved.description || resolved.description.length <= 300, "optional", `Description is ${resolved.description.length} chars.`, ), - ]; + ); return { requestedUrl: baseUrl, diff --git a/.github/extensions/og-preview/ui/app.js b/.github/extensions/og-preview/ui/app.js index bf99e6071..152773702 100644 --- a/.github/extensions/og-preview/ui/app.js +++ b/.github/extensions/og-preview/ui/app.js @@ -1415,6 +1415,12 @@ const DIAG_HELP = { example: '<meta property="og:site_name" content="Your Site" />', docs: { href: OGP + "#optional", label: "Open Graph optional metadata" }, }, + "og:locale": { + why: "og:locale tells platforms the language/territory of the content so they can localize the card and pick alternates. It defaults to en_US when omitted.", + fix: "Add og:locale in language_TERRITORY form, and og:locale:alternate for any other languages the page is available in.", + example: '<meta property="og:locale" content="en_US" />', + docs: { href: OGP + "#optional", label: "Open Graph optional metadata" }, + }, "twitter:card": { why: "twitter:card selects the X / Twitter card layout. Without it X uses a minimal fallback.", fix: 'Add twitter:card — use "summary_large_image" when you have a wide preview image, otherwise "summary".', @@ -1427,6 +1433,30 @@ const DIAG_HELP = { example: '<meta property="og:image" content="https://example.com/preview.png" />', docs: { href: OGP + "#metadata", label: "Open Graph protocol" }, }, + "og:image:alt": { + why: "og:image:alt describes the preview image for screen readers and low-bandwidth fallbacks. ogp.me states that if a page specifies og:image it should also specify og:image:alt.", + fix: "Add og:image:alt with a short description of what's in the image (a description, not a caption).", + example: '<meta property="og:image:alt" content="A shiny red apple with a bite taken out" />', + docs: { href: OGP + "#structured", label: "Open Graph structured properties" }, + }, + "og:image dimensions": { + why: "Declaring og:image:width and og:image:height lets platforms lay out and render the card immediately, before the image is fetched — avoiding layout shift and wrong cropping.", + fix: "Add og:image:width and og:image:height (in pixels) matching your preview image — 1200×630 is the common 1.91:1 size.", + example: '<meta property="og:image:width" content="1200" />\n<meta property="og:image:height" content="630" />', + docs: { href: OGP + "#structured", label: "Open Graph structured properties" }, + }, + "og:image:type": { + why: "og:image:type advertises the image's MIME type so crawlers can validate and decode it without sniffing.", + fix: "Add og:image:type with the image's MIME type (e.g. image/png, image/jpeg).", + example: '<meta property="og:image:type" content="image/png" />', + docs: { href: OGP + "#structured", label: "Open Graph structured properties" }, + }, + "og:image:secure_url": { + why: "Some platforms require an HTTPS image URL to display the preview. If og:image is HTTP, og:image:secure_url provides the HTTPS alternative.", + fix: "Serve og:image over HTTPS, or add og:image:secure_url with the HTTPS version of the image.", + example: '<meta property="og:image:secure_url" content="https://secure.example.com/preview.png" />', + docs: { href: OGP + "#structured", label: "Open Graph structured properties" }, + }, "Description length OK": { why: "Long descriptions get truncated mid-sentence; very short ones look empty. ~55–200 characters renders cleanly across platforms.", fix: "Trim or expand og:description to roughly 55–200 characters.", From 6593c2865d13f7f5917466f017029f3bde2b5494 Mon Sep 17 00:00:00 2001 From: David Pine <7679720+IEvangelist@users.noreply.github.com> Date: Wed, 1 Jul 2026 10:06:56 -0500 Subject: [PATCH 17/21] Replace Try chip with an 'I'm feeling lucky' rotating site button Replace the header "Try aspire.dev" chip with a colorized "I'm feeling lucky" button group: the gradient main button previews a random site (showing the current target host as a pill), and a shuffle button (official sync octicon, spin animation) retargets to a different random site without loading. Rotates over a curated list of OG-rich sites (aspire.dev, github.com, microsoft.com, astro.build, grpc.io, docker.com, typescriptlang.org, reddit, vercel, nextjs, MDN, stripe, cloudflare, nasa, nodejs, bsky). The empty-state "Try aspire.dev" chip becomes a larger "I'm feeling lucky" button that loads a fresh random site. Removes the now-unused .examples/.chip CSS and data-example handler. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .github/extensions/og-preview/ui/app.js | 76 ++++++++++- .github/extensions/og-preview/ui/index.html | 17 ++- .github/extensions/og-preview/ui/styles.css | 137 ++++++++++++++++---- 3 files changed, 199 insertions(+), 31 deletions(-) diff --git a/.github/extensions/og-preview/ui/app.js b/.github/extensions/og-preview/ui/app.js index 152773702..bc3365416 100644 --- a/.github/extensions/og-preview/ui/app.js +++ b/.github/extensions/og-preview/ui/app.js @@ -2208,13 +2208,81 @@ input.addEventListener("blur", () => { if (input.value.trim() === "https://" || input.value.trim() === "http://") input.value = "https://"; }); -document.querySelectorAll("[data-example]").forEach((btn) => { - btn.addEventListener("click", () => { - const url = btn.getAttribute("data-example"); +// "I'm feeling lucky" — a rotating set of sites with rich OG metadata. The main +// button previews the current target; the shuffle button retargets to another. +const LUCKY_SITES = [ + "https://aspire.dev", + "https://github.com", + "https://microsoft.com", + "https://astro.build", + "https://grpc.io", + "https://www.docker.com", + "https://www.typescriptlang.org", + "https://www.reddit.com", + "https://vercel.com", + "https://nextjs.org", + "https://developer.mozilla.org", + "https://stripe.com", + "https://www.cloudflare.com", + "https://www.nasa.gov", + "https://nodejs.org", + "https://bsky.app", +]; + +function luckyHostLabel(url) { + try { + return new URL(url).hostname.replace(/^www\./, ""); + } catch { + return url; + } +} + +function pickLuckySite(exclude) { + const pool = LUCKY_SITES.filter((s) => s !== exclude); + const list = pool.length ? pool : LUCKY_SITES; + return list[Math.floor(Math.random() * list.length)]; +} + +let luckyTarget = pickLuckySite(); + +function setLuckyTarget(url) { + luckyTarget = url; + const hostEl = $("#lucky-host"); + if (hostEl) hostEl.textContent = luckyHostLabel(url); + const go = $("#lucky-go"); + if (go) go.setAttribute("title", `Preview ${luckyHostLabel(url)}`); +} + +setLuckyTarget(luckyTarget); + +const luckyGo = $("#lucky-go"); +if (luckyGo) { + luckyGo.addEventListener("click", () => { + input.value = luckyTarget; + load(luckyTarget); + }); +} + +const luckyShuffle = $("#lucky-shuffle"); +if (luckyShuffle) { + luckyShuffle.addEventListener("click", () => { + setLuckyTarget(pickLuckySite(luckyTarget)); + luckyShuffle.classList.remove("spin"); + // reflow so the animation restarts on every click + void luckyShuffle.offsetWidth; + luckyShuffle.classList.add("spin"); + }); +} + +// Empty-state lucky button loads a fresh random site each click. +const luckyEmpty = $("#lucky-empty"); +if (luckyEmpty) { + luckyEmpty.addEventListener("click", () => { + const url = pickLuckySite(); input.value = url; load(url); }); -}); +} const TAB_KEY = "og-preview:tab"; const TAB_ICONS = { previews: "image", raw: "code", diagnostics: "checklist", browse: "globe" }; diff --git a/.github/extensions/og-preview/ui/index.html b/.github/extensions/og-preview/ui/index.html index 58c38a527..d565da1ea 100644 --- a/.github/extensions/og-preview/ui/index.html +++ b/.github/extensions/og-preview/ui/index.html @@ -55,9 +55,15 @@ <button class="tab" data-tab="raw" id="tab-raw" role="tab" aria-selected="false" aria-controls="panel-raw" tabindex="-1">Raw metadata</button> <button class="tab" data-tab="diagnostics" id="tab-diagnostics" role="tab" aria-selected="false" aria-controls="panel-diagnostics" tabindex="-1">Diagnostics</button> </nav> - <div class="examples"> - <span class="examples-label muted">Try</span> - <button class="chip" data-example="https://aspire.dev" type="button">aspire.dev</button> + <div class="lucky" role="group" aria-label="Try a random site"> + <button class="lucky-btn" id="lucky-go" type="button" title="Preview a random site"> + <span class="lucky-ico" aria-hidden="true"><svg width="14" height="14" viewBox="0 0 16 16"><path fill="currentColor" d="M7.53 1.282a.5.5 0 0 1 .94 0l.478 1.306a7.492 7.492 0 0 0 4.464 4.464l1.305.478a.5.5 0 0 1 0 .94l-1.305.478a7.492 7.492 0 0 0-4.464 4.464l-.478 1.305a.5.5 0 0 1-.94 0l-.478-1.305a7.492 7.492 0 0 0-4.464-4.464L1.282 8.47a.5.5 0 0 1 0-.94l1.306-.478a7.492 7.492 0 0 0 4.464-4.464Z"/></svg></span> + <span class="lucky-label">I'm feeling lucky</span> + <span class="lucky-host" id="lucky-host"></span> + </button> + <button class="lucky-shuffle" id="lucky-shuffle" type="button" title="Shuffle to a different site" aria-label="Shuffle to a different site"> + <span class="lucky-shuffle-ico" aria-hidden="true"><svg width="14" height="14" viewBox="0 0 16 16"><path fill="currentColor" d="M1.705 8.005a.75.75 0 0 1 .834.656 5.5 5.5 0 0 0 9.592 2.97l-1.204-1.204a.25.25 0 0 1 .177-.427h3.646a.25.25 0 0 1 .25.25v3.646a.25.25 0 0 1-.427.177l-1.38-1.38A7.002 7.002 0 0 1 1.05 8.84a.75.75 0 0 1 .656-.834ZM8 2.5a5.487 5.487 0 0 0-4.131 1.869l1.204 1.204A.25.25 0 0 1 4.896 6H1.25A.25.25 0 0 1 1 5.75V2.104a.25.25 0 0 1 .427-.177l1.38 1.38A7.002 7.002 0 0 1 14.95 7.16a.75.75 0 0 1-1.49.178A5.5 5.5 0 0 0 8 2.5Z"/></svg></span> + </button> </div> </div> <div id="progress" class="progress" aria-hidden="true"> @@ -122,7 +128,10 @@ <h2 class="previews-title">Link previews</h2> Local dev servers like <code>localhost:3000</code> work too. </p> <div class="empty-actions"> - <button class="chip" data-example="https://aspire.dev" type="button">Try aspire.dev</button> + <button class="lucky-btn lucky-btn-lg" id="lucky-empty" type="button" title="Preview a random site"> + <span class="lucky-ico" aria-hidden="true"><svg width="15" height="15" viewBox="0 0 16 16"><path fill="currentColor" d="M7.53 1.282a.5.5 0 0 1 .94 0l.478 1.306a7.492 7.492 0 0 0 4.464 4.464l1.305.478a.5.5 0 0 1 0 .94l-1.305.478a7.492 7.492 0 0 0-4.464 4.464l-.478 1.305a.5.5 0 0 1-.94 0l-.478-1.305a7.492 7.492 0 0 0-4.464-4.464L1.282 8.47a.5.5 0 0 1 0-.94l1.306-.478a7.492 7.492 0 0 0 4.464-4.464Z"/></svg></span> + <span class="lucky-label">I'm feeling lucky</span> + </button> </div> </div> </main> diff --git a/.github/extensions/og-preview/ui/styles.css b/.github/extensions/og-preview/ui/styles.css index 5fa6b5c04..41336f463 100644 --- a/.github/extensions/og-preview/ui/styles.css +++ b/.github/extensions/og-preview/ui/styles.css @@ -335,42 +335,133 @@ code { outline-offset: 1px; } -/* Example chips */ -.examples { - display: flex; - align-items: center; - gap: 8px; +/* "I'm feeling lucky" button group */ +.lucky { + display: inline-flex; + align-items: stretch; flex-shrink: 0; + border-radius: 999px; + box-shadow: 0 1px 2px rgba(31, 35, 40, 0.16); } -.examples-label { +.lucky-btn { + display: inline-flex; + align-items: center; + gap: 7px; + height: 28px; + padding: 0 13px; + border: none; + border-radius: 999px; + color: #ffffff; font-size: 12px; - letter-spacing: 0.01em; + font-weight: var(--font-weight-semibold, 600); + cursor: pointer; + background: linear-gradient( + 100deg, + #8957e5 0%, + #0969da 45%, + #0da487 78%, + #d29922 130% + ); + background-size: 180% 100%; + background-position: 0% 50%; + transition: background-position 0.5s ease, filter 0.12s ease, box-shadow 0.12s ease; +} +.lucky-btn:hover { + background-position: 100% 50%; + filter: saturate(1.15) brightness(1.04); +} +.lucky-btn:active { + filter: brightness(0.96); } -.chip { - height: 26px; - padding: 0 10px; +/* Header variant: square off the right edge so it butts against the shuffle button. */ +.lucky .lucky-btn { + border-top-right-radius: 0; + border-bottom-right-radius: 0; + padding-right: 11px; +} + +.lucky-ico { + display: inline-flex; + color: #fff; + filter: drop-shadow(0 0 5px rgba(255, 255, 255, 0.55)); +} +.lucky-ico svg { + display: block; +} + +.lucky-label { + white-space: nowrap; +} + +.lucky-host { + max-width: 130px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + padding: 1px 8px; border-radius: 999px; - border: 1px solid var(--border-color-default, #d1d9e0); - background: var(--surface-raised); - color: var(--text-color-default, #1f2328); - font-size: 12px; - font-weight: var(--font-weight-semibold, 600); font-family: var(--font-mono, monospace); - cursor: pointer; - transition: background-color 0.12s ease, border-color 0.12s ease, color 0.12s ease; + font-size: 11px; + font-weight: var(--font-weight-semibold, 600); + color: #fff; + background: rgba(255, 255, 255, 0.22); } -.chip:hover { - background: var(--accent-soft); - border-color: var(--accent); - color: var(--text-color-default, #1f2328); +.lucky-shuffle { + display: inline-flex; + align-items: center; + justify-content: center; + width: 30px; + height: 28px; + border: none; + border-left: 1px solid rgba(255, 255, 255, 0.28); + border-radius: 0 999px 999px 0; + color: #fff; + cursor: pointer; + background: linear-gradient(100deg, #0da487 0%, #1a7f37 120%); + transition: filter 0.12s ease; +} +.lucky-shuffle:hover { + filter: saturate(1.15) brightness(1.06); +} +.lucky-shuffle-ico { + display: inline-flex; +} +.lucky-shuffle-ico svg { + display: block; +} +.lucky-shuffle.spin .lucky-shuffle-ico { + animation: lucky-spin 0.5s cubic-bezier(0.34, 1.4, 0.5, 1); +} +@keyframes lucky-spin { + from { + transform: rotate(0deg); + } + to { + transform: rotate(360deg); + } +} +@media (prefers-reduced-motion: reduce) { + .lucky-shuffle.spin .lucky-shuffle-ico { + animation: none; + } } -.chip:focus-visible { +.lucky-btn:focus-visible, +.lucky-shuffle:focus-visible { outline: 2px solid var(--accent); - outline-offset: 1px; + outline-offset: 2px; +} + +/* Larger standalone variant used in the empty state. */ +.lucky-btn-lg { + height: 34px; + padding: 0 18px; + font-size: 13px; + gap: 8px; + box-shadow: 0 1px 2px rgba(31, 35, 40, 0.16); } /* ---------- Status ---------- */ From af90bd3958e02f0a3fd6b5678fbdecd610b567e1 Mon Sep 17 00:00:00 2001 From: David Pine <7679720+IEvangelist@users.noreply.github.com> Date: Wed, 1 Jul 2026 10:11:34 -0500 Subject: [PATCH 18/21] Tone down the I'm feeling lucky button styling Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .github/extensions/og-preview/ui/styles.css | 55 ++++++++++----------- 1 file changed, 25 insertions(+), 30 deletions(-) diff --git a/.github/extensions/og-preview/ui/styles.css b/.github/extensions/og-preview/ui/styles.css index 41336f463..bd411d884 100644 --- a/.github/extensions/og-preview/ui/styles.css +++ b/.github/extensions/og-preview/ui/styles.css @@ -340,8 +340,10 @@ code { display: inline-flex; align-items: stretch; flex-shrink: 0; + border: 1px solid var(--border-color-default, #d1d9e0); border-radius: 999px; - box-shadow: 0 1px 2px rgba(31, 35, 40, 0.16); + overflow: hidden; + background: var(--surface-raised); } .lucky-btn { @@ -352,40 +354,26 @@ code { padding: 0 13px; border: none; border-radius: 999px; - color: #ffffff; + background: transparent; + color: var(--text-color-default, #1f2328); font-size: 12px; font-weight: var(--font-weight-semibold, 600); cursor: pointer; - background: linear-gradient( - 100deg, - #8957e5 0%, - #0969da 45%, - #0da487 78%, - #d29922 130% - ); - background-size: 180% 100%; - background-position: 0% 50%; - transition: background-position 0.5s ease, filter 0.12s ease, box-shadow 0.12s ease; + transition: background-color 0.12s ease, color 0.12s ease; } .lucky-btn:hover { - background-position: 100% 50%; - filter: saturate(1.15) brightness(1.04); -} -.lucky-btn:active { - filter: brightness(0.96); + background: var(--surface-inset); } /* Header variant: square off the right edge so it butts against the shuffle button. */ .lucky .lucky-btn { - border-top-right-radius: 0; - border-bottom-right-radius: 0; + border-radius: 0; padding-right: 11px; } .lucky-ico { display: inline-flex; - color: #fff; - filter: drop-shadow(0 0 5px rgba(255, 255, 255, 0.55)); + color: var(--accent); } .lucky-ico svg { display: block; @@ -405,8 +393,8 @@ code { font-family: var(--font-mono, monospace); font-size: 11px; font-weight: var(--font-weight-semibold, 600); - color: #fff; - background: rgba(255, 255, 255, 0.22); + color: var(--accent); + background: var(--accent-soft); } .lucky-shuffle { @@ -416,15 +404,15 @@ code { width: 30px; height: 28px; border: none; - border-left: 1px solid rgba(255, 255, 255, 0.28); - border-radius: 0 999px 999px 0; - color: #fff; + border-left: 1px solid var(--border-color-default, #d1d9e0); + background: transparent; + color: var(--text-color-muted, #59636e); cursor: pointer; - background: linear-gradient(100deg, #0da487 0%, #1a7f37 120%); - transition: filter 0.12s ease; + transition: background-color 0.12s ease, color 0.12s ease; } .lucky-shuffle:hover { - filter: saturate(1.15) brightness(1.06); + background: var(--surface-inset); + color: var(--accent); } .lucky-shuffle-ico { display: inline-flex; @@ -461,7 +449,14 @@ code { padding: 0 18px; font-size: 13px; gap: 8px; - box-shadow: 0 1px 2px rgba(31, 35, 40, 0.16); + border: 1px solid var(--border-color-default, #d1d9e0); + border-radius: 999px; + background: var(--surface-raised); + box-shadow: var(--shadow-sm); +} +.lucky-btn-lg:hover { + background: var(--surface-inset); + border-color: var(--accent); } /* ---------- Status ---------- */ From 894a480ea4993f499ca867c96c07f9a3fdefa608 Mon Sep 17 00:00:00 2001 From: David Pine <7679720+IEvangelist@users.noreply.github.com> Date: Wed, 1 Jul 2026 10:15:52 -0500 Subject: [PATCH 19/21] Make diagnostics sections collapsible Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .github/extensions/og-preview/ui/app.js | 15 +++++++++--- .github/extensions/og-preview/ui/styles.css | 26 +++++++++++++++++++++ 2 files changed, 38 insertions(+), 3 deletions(-) diff --git a/.github/extensions/og-preview/ui/app.js b/.github/extensions/og-preview/ui/app.js index bc3365416..cddba7d76 100644 --- a/.github/extensions/og-preview/ui/app.js +++ b/.github/extensions/og-preview/ui/app.js @@ -1732,21 +1732,30 @@ function ogDiagItem(c, data) { return details; } +function secChevron() { + return el("span", { + class: "diag-sec-chevron", + "aria-hidden": "true", + html: '<svg width="14" height="14" viewBox="0 0 16 16" fill="none"><path d="M6 4l4 4-4 4" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"/></svg>', + }); +} + function diagSectionHead(iconName, title, badge) { - return el("div", { class: "diag-sec-head" }, [ + return el("summary", { class: "diag-sec-head" }, [ octicon(iconName, 15, "diag-sec-ico"), el("span", { class: "diag-sec-title", text: title }), badge ? el("span", { class: "diag-sec-badge", text: badge }) : null, + secChevron(), ]); } function renderDiagnostics(data) { const host = $("#diagnostics"); - const ogSection = el("div", { class: "diag-section" }, [ + const ogSection = el("details", { class: "diag-section", open: "open" }, [ diagSectionHead("checklist", "Social & OpenGraph metadata"), el("div", { class: "diag-list" }, data.diagnostics.map((c) => ogDiagItem(c, data))), ]); - const arSection = el("div", { class: "diag-section" }, [ + const arSection = el("details", { class: "diag-section", open: "open" }, [ diagSectionHead("rocket", "Agent readiness", "experimental"), el("div", { class: "ar-intro" }, [ document.createTextNode( diff --git a/.github/extensions/og-preview/ui/styles.css b/.github/extensions/og-preview/ui/styles.css index bd411d884..727aec8a1 100644 --- a/.github/extensions/og-preview/ui/styles.css +++ b/.github/extensions/og-preview/ui/styles.css @@ -1810,6 +1810,32 @@ body[data-mode="dark"] .cl-t .hl-code { color: var(--color-prettylights-syntax-s display: flex; align-items: center; gap: 8px; + cursor: pointer; + user-select: none; + list-style: none; + padding: 5px 8px; + border-radius: var(--radius-md, 6px); + transition: background-color 0.12s ease; +} +.diag-sec-head::-webkit-details-marker { + display: none; +} +.diag-sec-head:hover { + background: var(--surface-inset); +} +.diag-sec-head:focus-visible { + outline: 2px solid var(--accent); + outline-offset: 2px; +} +.diag-sec-chevron { + margin-left: auto; + display: inline-flex; + flex: 0 0 auto; + color: var(--text-color-muted, #59636e); + transition: transform 0.22s cubic-bezier(0.22, 0.61, 0.36, 1); +} +details.diag-section[open] > summary .diag-sec-chevron { + transform: rotate(90deg); } .diag-sec-ico { color: var(--text-color-muted, #59636e); From 5a6544e3ee24d13aa2e8c9f91420d944460525e3 Mon Sep 17 00:00:00 2001 From: David Pine <7679720+IEvangelist@users.noreply.github.com> Date: Wed, 1 Jul 2026 10:19:19 -0500 Subject: [PATCH 20/21] Align collapsible diagnostics section headers with content Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .github/extensions/og-preview/ui/styles.css | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/.github/extensions/og-preview/ui/styles.css b/.github/extensions/og-preview/ui/styles.css index 727aec8a1..ad7361620 100644 --- a/.github/extensions/og-preview/ui/styles.css +++ b/.github/extensions/og-preview/ui/styles.css @@ -1813,15 +1813,18 @@ body[data-mode="dark"] .cl-t .hl-code { color: var(--color-prettylights-syntax-s cursor: pointer; user-select: none; list-style: none; - padding: 5px 8px; + padding: 6px 0; border-radius: var(--radius-md, 6px); - transition: background-color 0.12s ease; + transition: color 0.12s ease; } .diag-sec-head::-webkit-details-marker { display: none; } -.diag-sec-head:hover { - background: var(--surface-inset); +.diag-sec-head:hover .diag-sec-title { + color: var(--accent); +} +.diag-sec-head:hover .diag-sec-chevron { + color: var(--accent); } .diag-sec-head:focus-visible { outline: 2px solid var(--accent); From e7e841bb88df80b28e987a6aa291bdb5ad6c952c Mon Sep 17 00:00:00 2001 From: David Pine <7679720+IEvangelist@users.noreply.github.com> Date: Wed, 1 Jul 2026 10:26:19 -0500 Subject: [PATCH 21/21] Slim scrollbars and drop reddit/bsky from lucky sites Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .github/extensions/og-preview/ui/app.js | 2 -- .github/extensions/og-preview/ui/styles.css | 4 ++-- 2 files changed, 2 insertions(+), 4 deletions(-) diff --git a/.github/extensions/og-preview/ui/app.js b/.github/extensions/og-preview/ui/app.js index cddba7d76..a50e935c0 100644 --- a/.github/extensions/og-preview/ui/app.js +++ b/.github/extensions/og-preview/ui/app.js @@ -2227,7 +2227,6 @@ const LUCKY_SITES = [ "https://grpc.io", "https://www.docker.com", "https://www.typescriptlang.org", - "https://www.reddit.com", "https://vercel.com", "https://nextjs.org", "https://developer.mozilla.org", @@ -2235,7 +2234,6 @@ const LUCKY_SITES = [ "https://www.cloudflare.com", "https://www.nasa.gov", "https://nodejs.org", - "https://bsky.app", ]; function luckyHostLabel(url) { diff --git a/.github/extensions/og-preview/ui/styles.css b/.github/extensions/og-preview/ui/styles.css index ad7361620..c5c84017a 100644 --- a/.github/extensions/og-preview/ui/styles.css +++ b/.github/extensions/og-preview/ui/styles.css @@ -71,8 +71,8 @@ custom ::-webkit-scrollbar below, where `-button { display:none }` truly removes the carets. */ *::-webkit-scrollbar { - width: 11px; - height: 11px; + width: 8px; + height: 8px; } *::-webkit-scrollbar-track { background: transparent;