feat(achievements): share card render on unlocked badges (#19657)

* feat(achievements): share card render on unlocked badges

Adds a Share button to each unlocked achievement card that opens a
modal and renders a 1200x630 PNG share card client-side via Canvas2D
(no backend, no network, no new deps). Two actions: Download PNG and
Copy image to clipboard.

Card layout mirrors the in-dashboard visual language: tier-colored
glow, icon from the existing LUCIDE sprite set, achievement name,
tier badge pill, description, progress stat line, and a Hermes Agent
watermark. Sized for X/Twitter, Discord, LinkedIn, Bluesky link
previews.

Vendored on top of the upstream @PCinkusz bundle; the 'in-progress
scan banner' precedent already established this divergence pattern.
Manifest bumped 0.3.1 -> 0.4.0.

* feat(achievements): share-on-X as primary action on share dialog

Adds a 'Share on X' button as the primary action in the share dialog.
Opens https://x.com/intent/post with a pre-filled tweet referencing
the achievement name, tier, @NousResearch, and the Hermes docs URL.
Copy image and Download PNG become secondary actions: users who want
the badge attached can Copy image, paste into the X composer, post.

Primary button styled as X's signature black-on-white fill so the
action is unambiguous.
This commit is contained in:
Teknium 2026-05-04 04:47:53 -07:00 committed by GitHub
parent 297eaa3533
commit c5789f4309
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
4 changed files with 332 additions and 3 deletions

View file

@ -11,6 +11,8 @@ Achievement system for the Hermes Dashboard: collectible, tiered badges generate
The screenshots use temporary demo tier data to show the full visual range. The plugin itself reads real local Hermes session history by default. The screenshots use temporary demo tier data to show the full visual range. The plugin itself reads real local Hermes session history by default.
> **Update notice (2026-04-29):** If you installed this plugin before today, update to the latest version. The achievements scan path was refactored for much faster warm loads (snapshot cache + incremental checkpoint scan). > **Update notice (2026-04-29):** If you installed this plugin before today, update to the latest version. The achievements scan path was refactored for much faster warm loads (snapshot cache + incremental checkpoint scan).
>
> **Share cards (2026-05-04, vendored in hermes-agent v0.4.0):** Unlocked achievement cards now have a "Share" button that renders a 1200×630 PNG share card (client-side canvas, no backend, no network) with Download + Copy-to-clipboard actions. Fits X/Twitter, Discord, LinkedIn, Bluesky link-preview dimensions.
## What it does ## What it does

View file

@ -66,6 +66,296 @@
}); });
} }
const TIER_HEX = {
"Copper": "#b87333",
"Silver": "#c0c7d2",
"Gold": "#f2c94c",
"Diamond": "#67e8f9",
"Olympian": "#c084fc",
};
function tierHex(tier) {
return TIER_HEX[tier] || "#67e8f9";
}
// Render a LUCIDE icon path fragment into a standalone SVG string with an
// explicit stroke color so it can be rasterized onto a <canvas> via Image.
// The normal render path uses stroke="currentColor" which browsers honor in
// DOM but NOT when the SVG is drawn to a canvas from a data URL.
function iconSvgForCanvas(iconKey, strokeColor) {
const paths = LUCIDE[iconKey] || LUCIDE.secret;
return "<svg xmlns=\"http://www.w3.org/2000/svg\" viewBox=\"0 0 24 24\" fill=\"none\" " +
"stroke=\"" + strokeColor + "\" stroke-width=\"2\" stroke-linecap=\"round\" stroke-linejoin=\"round\">" +
paths + "</svg>";
}
function loadSvgImage(svgString) {
return new Promise(function (resolve, reject) {
const blob = new Blob([svgString], { type: "image/svg+xml;charset=utf-8" });
const url = URL.createObjectURL(blob);
const img = new Image();
img.onload = function () { URL.revokeObjectURL(url); resolve(img); };
img.onerror = function (e) { URL.revokeObjectURL(url); reject(e); };
img.src = url;
});
}
function wrapText(ctx, text, maxWidth) {
const words = String(text || "").split(/\s+/).filter(Boolean);
const lines = [];
let current = "";
for (let i = 0; i < words.length; i++) {
const candidate = current ? current + " " + words[i] : words[i];
if (ctx.measureText(candidate).width <= maxWidth) {
current = candidate;
} else {
if (current) lines.push(current);
current = words[i];
}
}
if (current) lines.push(current);
return lines;
}
// Build a 1200x630 share card PNG for a single achievement. Returns a Blob.
// Pure client-side render via Canvas2D — no external deps, no network.
async function buildShareImage(achievement) {
const W = 1200;
const H = 630;
const canvas = document.createElement("canvas");
canvas.width = W;
canvas.height = H;
const ctx = canvas.getContext("2d");
const tier = achievement.tier || achievement.next_tier || "Copper";
const color = tierHex(tier);
// Background: dark charcoal with a tier-tinted radial highlight on the
// top-left, echoing the card visual language.
ctx.fillStyle = "#0b0d11";
ctx.fillRect(0, 0, W, H);
const bgGrad = ctx.createRadialGradient(260, 220, 60, 260, 220, 820);
bgGrad.addColorStop(0, color + "33");
bgGrad.addColorStop(0.55, color + "0a");
bgGrad.addColorStop(1, "#0b0d1100");
ctx.fillStyle = bgGrad;
ctx.fillRect(0, 0, W, H);
// Outer border
ctx.strokeStyle = color + "66";
ctx.lineWidth = 2;
ctx.strokeRect(1, 1, W - 2, H - 2);
// Icon block — 380x380 on the left
try {
const svg = iconSvgForCanvas(achievement.icon || "secret", color);
const iconImg = await loadSvgImage(svg);
const ix = 90;
const iy = 125;
const isize = 380;
// Icon glow
ctx.save();
ctx.shadowColor = color;
ctx.shadowBlur = 40;
ctx.drawImage(iconImg, ix, iy, isize, isize);
ctx.restore();
} catch (_) {
// Icon render failure is non-fatal; card still useful without it.
}
// Right column text layout
const rx = 520;
const rMaxWidth = W - rx - 70;
// Category label (kicker)
ctx.fillStyle = "#8b95a8";
ctx.font = "600 22px ui-monospace, 'SF Mono', Menlo, monospace";
ctx.textBaseline = "top";
ctx.fillText((achievement.category || "").toUpperCase(), rx, 112);
// Achievement name — wrap to 2 lines if needed
ctx.fillStyle = "#ffffff";
ctx.font = "780 68px system-ui, -apple-system, 'Segoe UI', sans-serif";
const nameLines = wrapText(ctx, achievement.name || "Achievement", rMaxWidth).slice(0, 2);
let cursorY = 150;
for (let i = 0; i < nameLines.length; i++) {
ctx.fillText(nameLines[i], rx, cursorY);
cursorY += 76;
}
// Tier badge pill
const badgeLabel = tier.toUpperCase() + " TIER";
ctx.font = "700 22px ui-monospace, 'SF Mono', Menlo, monospace";
const badgeWidth = ctx.measureText(badgeLabel).width + 32;
const badgeX = rx;
const badgeY = cursorY + 14;
const badgeH = 40;
ctx.fillStyle = color + "1f";
ctx.strokeStyle = color;
ctx.lineWidth = 1.5;
ctx.beginPath();
ctx.rect(badgeX, badgeY, badgeWidth, badgeH);
ctx.fill();
ctx.stroke();
ctx.fillStyle = color;
ctx.textBaseline = "middle";
ctx.fillText(badgeLabel, badgeX + 16, badgeY + badgeH / 2 + 1);
ctx.textBaseline = "top";
// Description — wrap up to 3 lines
ctx.fillStyle = "#c3cad6";
ctx.font = "400 26px system-ui, -apple-system, 'Segoe UI', sans-serif";
const descLines = wrapText(ctx, achievement.description || "", rMaxWidth).slice(0, 3);
let descY = badgeY + badgeH + 28;
for (let i = 0; i < descLines.length; i++) {
ctx.fillText(descLines[i], rx, descY);
descY += 34;
}
// Progress / stat line (if meaningful)
const progressValue = achievement.progress;
const threshold = achievement.next_threshold;
let statLine = null;
if (progressValue && threshold) {
statLine = progressValue.toLocaleString() + " / " + threshold.toLocaleString();
} else if (progressValue) {
statLine = progressValue.toLocaleString();
}
if (statLine) {
ctx.fillStyle = color;
ctx.font = "700 28px ui-monospace, 'SF Mono', Menlo, monospace";
ctx.fillText(statLine, rx, descY + 14);
}
// Footer watermark
ctx.fillStyle = "#8b95a8";
ctx.font = "600 20px ui-monospace, 'SF Mono', Menlo, monospace";
ctx.textBaseline = "bottom";
ctx.fillText("HERMES AGENT · hermes-agent.nousresearch.com", 70, H - 40);
// "UNLOCKED" stamp upper-right
ctx.textBaseline = "top";
ctx.fillStyle = color;
ctx.font = "800 24px ui-monospace, 'SF Mono', Menlo, monospace";
const stamp = "◆ UNLOCKED";
const stampW = ctx.measureText(stamp).width;
ctx.fillText(stamp, W - 70 - stampW, 70);
return await new Promise(function (resolve, reject) {
canvas.toBlob(function (blob) {
if (blob) resolve(blob); else reject(new Error("canvas.toBlob returned null"));
}, "image/png");
});
}
function ShareDialog({ achievement, onClose }) {
const [status, setStatus] = hooks.useState("rendering"); // rendering | ready | copied | error
const [errorMsg, setErrorMsg] = hooks.useState(null);
const [previewUrl, setPreviewUrl] = hooks.useState(null);
const blobRef = React.useRef(null);
hooks.useEffect(function () {
let cancelled = false;
let createdUrl = null;
buildShareImage(achievement).then(function (blob) {
if (cancelled) return;
blobRef.current = blob;
createdUrl = URL.createObjectURL(blob);
setPreviewUrl(createdUrl);
setStatus("ready");
}).catch(function (err) {
if (cancelled) return;
setErrorMsg(String(err && err.message || err));
setStatus("error");
});
return function () {
cancelled = true;
if (createdUrl) URL.revokeObjectURL(createdUrl);
};
}, [achievement.id]);
function download() {
if (!blobRef.current) return;
const url = URL.createObjectURL(blobRef.current);
const a = document.createElement("a");
a.href = url;
a.download = "hermes-achievement-" + (achievement.id || "badge") + ".png";
document.body.appendChild(a);
a.click();
a.remove();
setTimeout(function () { URL.revokeObjectURL(url); }, 1000);
}
async function copyToClipboard() {
if (!blobRef.current) return;
try {
if (!navigator.clipboard || !window.ClipboardItem) {
throw new Error("Clipboard image copy not supported in this browser — use Download instead.");
}
await navigator.clipboard.write([
new window.ClipboardItem({ "image/png": blobRef.current }),
]);
setStatus("copied");
setTimeout(function () { setStatus("ready"); }, 1800);
} catch (err) {
setErrorMsg(String(err && err.message || err));
setStatus("error");
}
}
// Build the pre-filled tweet text. Keep it short so X doesn't truncate
// when the user hasn't attached the PNG yet — they'll copy-image and
// paste in the same flow.
function tweetText() {
const tierPart = achievement.tier ? (achievement.tier + " tier ") : "";
return "Just unlocked " + tierPart + "\"" + achievement.name + "\" in Hermes Agent ☤\n\n" +
"@NousResearch · https://hermes-agent.nousresearch.com";
}
function shareOnX() {
const url = "https://x.com/intent/post?text=" + encodeURIComponent(tweetText());
window.open(url, "_blank", "noopener,noreferrer");
}
return React.createElement("div", {
className: "ha-share-backdrop",
onClick: function (e) { if (e.target === e.currentTarget) onClose(); },
},
React.createElement("div", { className: "ha-share-dialog", role: "dialog", "aria-label": "Share achievement" },
React.createElement("div", { className: "ha-share-head" },
React.createElement("strong", null, "Share: " + achievement.name),
React.createElement("button", { className: "ha-share-close", onClick: onClose, "aria-label": "Close" }, "×")
),
React.createElement("div", { className: "ha-share-preview" },
status === "rendering" && React.createElement("div", { className: "ha-share-placeholder" }, "Rendering…"),
previewUrl && React.createElement("img", { src: previewUrl, alt: achievement.name + " share card" })
),
status === "error" && React.createElement("div", { className: "ha-share-error" }, errorMsg || "Something went wrong."),
React.createElement("div", { className: "ha-share-actions" },
React.createElement("button", {
className: "ha-share-btn ha-share-btn-primary",
onClick: shareOnX,
title: "Opens X with a pre-filled post",
}, "Share on X"),
React.createElement("button", {
className: "ha-share-btn",
onClick: copyToClipboard,
disabled: status !== "ready" && status !== "copied",
title: "Copy the image to paste into your post",
}, status === "copied" ? "Copied ✓" : "Copy image"),
React.createElement("button", {
className: "ha-share-btn",
onClick: download,
disabled: status !== "ready" && status !== "copied",
}, "Download PNG")
),
React.createElement("p", { className: "ha-share-hint" },
"Share on X opens a pre-filled post in a new tab. Click Copy image first if you want the 1200×630 badge attached — X lets you paste it right into the tweet composer. Download PNG saves the file for use anywhere."
)
)
);
}
function StatCard(props) { function StatCard(props) {
return React.createElement(C.Card, { className: "ha-stat" }, return React.createElement(C.Card, { className: "ha-stat" },
React.createElement(C.CardContent, { className: "ha-stat-content" }, React.createElement(C.CardContent, { className: "ha-stat-content" },
@ -170,6 +460,7 @@
const targetTier = achievement.next_tier || achievement.tier; const targetTier = achievement.next_tier || achievement.tier;
const tierLabel = achievement.tier ? achievement.tier : (targetTier ? "Target " + targetTier : (state === "secret" ? "Hidden" : (unlocked ? "Complete" : "Objective"))); const tierLabel = achievement.tier ? achievement.tier : (targetTier ? "Target " + targetTier : (state === "secret" ? "Hidden" : (unlocked ? "Complete" : "Objective")));
const progressText = state === "secret" ? "hidden" : (progress + (achievement.next_threshold ? " / " + achievement.next_threshold : "")); const progressText = state === "secret" ? "hidden" : (progress + (achievement.next_threshold ? " / " + achievement.next_threshold : ""));
const [shareOpen, setShareOpen] = hooks.useState(false);
return React.createElement(C.Card, { className: cn("ha-card", "ha-state-" + state, tierClass(achievement.tier || achievement.next_tier)) }, return React.createElement(C.Card, { className: cn("ha-card", "ha-state-" + state, tierClass(achievement.tier || achievement.next_tier)) },
React.createElement(C.CardContent, { className: "ha-card-content" }, React.createElement(C.CardContent, { className: "ha-card-content" },
React.createElement("div", { className: "ha-card-head" }, React.createElement("div", { className: "ha-card-head" },
@ -180,7 +471,13 @@
), ),
React.createElement("div", { className: "ha-badges" }, React.createElement("div", { className: "ha-badges" },
React.createElement("span", { className: "ha-state-badge" }, stateLabel), React.createElement("span", { className: "ha-state-badge" }, stateLabel),
React.createElement("span", { className: "ha-tier-badge" }, tierLabel) React.createElement("span", { className: "ha-tier-badge" }, tierLabel),
state === "unlocked" && React.createElement("button", {
className: "ha-share-trigger",
onClick: function () { setShareOpen(true); },
title: "Share this achievement",
"aria-label": "Share " + achievement.name,
}, "Share")
) )
), ),
React.createElement("p", { className: "ha-description" }, achievement.description), React.createElement("p", { className: "ha-description" }, achievement.description),
@ -200,7 +497,11 @@
), ),
React.createElement("span", { className: "ha-progress-text" }, progressText) React.createElement("span", { className: "ha-progress-text" }, progressText)
) )
) ),
shareOpen && React.createElement(ShareDialog, {
achievement: achievement,
onClose: function () { setShareOpen(false); },
})
); );
} }

View file

@ -118,3 +118,29 @@
.ha-scan-banner-text p { margin: .25rem 0 0; font-size: .78rem; line-height: 1.35; color: var(--color-muted-foreground); text-transform: none; letter-spacing: normal; } .ha-scan-banner-text p { margin: .25rem 0 0; font-size: .78rem; line-height: 1.35; color: var(--color-muted-foreground); text-transform: none; letter-spacing: normal; }
.ha-scan-progress-track { height: .4rem; border: 1px solid color-mix(in srgb, #67e8f9 28%, var(--color-border)); background: rgba(0,0,0,.22); overflow: hidden; } .ha-scan-progress-track { height: .4rem; border: 1px solid color-mix(in srgb, #67e8f9 28%, var(--color-border)); background: rgba(0,0,0,.22); overflow: hidden; }
.ha-scan-progress-fill { height: 100%; background: linear-gradient(90deg, #67e8f9, color-mix(in srgb, #67e8f9 48%, white)); transition: width .4s ease-out; } .ha-scan-progress-fill { height: 100%; background: linear-gradient(90deg, #67e8f9, color-mix(in srgb, #67e8f9 48%, white)); transition: width .4s ease-out; }
/* Share achievement trigger button on unlocked cards + modal dialog.
* Added to the vendored bundle (on top of the upstream PCinkusz base).
* Canvas rendering is pure client-side, no backend, no network.
*/
.ha-share-trigger { border: 1px solid color-mix(in srgb, var(--ha-tier) 58%, var(--color-border)); color: var(--ha-tier); background: color-mix(in srgb, var(--ha-tier) 8%, transparent); padding: .18rem .42rem; font-size: .66rem; text-transform: uppercase; letter-spacing: .08em; font-family: var(--font-mono, ui-monospace, monospace); cursor: pointer; margin-top: .05rem; transition: background .12s ease, border-color .12s ease; }
.ha-share-trigger:hover { background: color-mix(in srgb, var(--ha-tier) 20%, transparent); border-color: var(--ha-tier); }
.ha-share-trigger:focus-visible { outline: 2px solid var(--ha-tier); outline-offset: 2px; }
.ha-share-backdrop { position: fixed; inset: 0; z-index: 1000; background: rgba(4,6,10,.72); backdrop-filter: blur(6px); display: flex; align-items: center; justify-content: center; padding: 1.5rem; animation: ha-fade-in .14s ease-out; }
.ha-share-dialog { width: min(760px, 100%); max-height: calc(100vh - 3rem); overflow: auto; border: 1px solid color-mix(in srgb, var(--color-border) 70%, var(--color-ring)); background: color-mix(in srgb, var(--color-card) 94%, #000); box-shadow: 0 24px 60px rgba(0,0,0,.55); display: flex; flex-direction: column; gap: .9rem; padding: 1rem 1.1rem 1.1rem; }
.ha-share-head { display: flex; align-items: center; justify-content: space-between; gap: .75rem; }
.ha-share-head strong { font-size: .82rem; text-transform: uppercase; letter-spacing: .1em; font-family: var(--font-mono, ui-monospace, monospace); color: var(--color-foreground); }
.ha-share-close { width: 1.9rem; height: 1.9rem; display: grid; place-items: center; border: 1px solid var(--color-border); background: transparent; color: var(--color-muted-foreground); font-size: 1.1rem; cursor: pointer; line-height: 1; }
.ha-share-close:hover { color: var(--color-foreground); border-color: var(--color-ring); }
.ha-share-preview { position: relative; border: 1px solid var(--color-border); background: #0b0d11; overflow: hidden; aspect-ratio: 1200 / 630; }
.ha-share-preview img { display: block; width: 100%; height: 100%; object-fit: contain; }
.ha-share-placeholder { position: absolute; inset: 0; display: grid; place-items: center; color: var(--color-muted-foreground); font-family: var(--font-mono, ui-monospace, monospace); font-size: .82rem; text-transform: uppercase; letter-spacing: .1em; animation: ha-pulse 1.4s ease-in-out infinite; border-radius: 0; }
.ha-share-error { border: 1px solid #ef4444; color: #fecaca; background: color-mix(in srgb, #ef4444 10%, transparent); padding: .55rem .7rem; font-size: .78rem; font-family: var(--font-mono, ui-monospace, monospace); }
.ha-share-actions { display: flex; gap: .55rem; flex-wrap: wrap; }
.ha-share-btn { border: 1px solid var(--color-border); background: color-mix(in srgb, var(--color-card) 72%, transparent); color: var(--color-foreground); padding: .5rem .85rem; font-size: .82rem; font-family: var(--font-mono, ui-monospace, monospace); text-transform: uppercase; letter-spacing: .08em; cursor: pointer; transition: border-color .12s ease, background .12s ease; }
.ha-share-btn:hover:not(:disabled) { border-color: var(--color-ring); background: color-mix(in srgb, var(--color-primary) 16%, var(--color-card)); }
.ha-share-btn:disabled { opacity: .5; cursor: not-allowed; }
.ha-share-btn-primary { border-color: #ffffff; color: #ffffff; background: #000000; }
.ha-share-btn-primary:hover:not(:disabled) { background: #1a1a1a; border-color: #67e8f9; color: #67e8f9; }
.ha-share-hint { margin: 0; color: var(--color-muted-foreground); font-size: .76rem; line-height: 1.45; }

View file

@ -3,7 +3,7 @@
"label": "Achievements", "label": "Achievements",
"description": "Steam-style achievements for vibe coding and agentic Hermes workflows.", "description": "Steam-style achievements for vibe coding and agentic Hermes workflows.",
"icon": "Star", "icon": "Star",
"version": "0.3.1", "version": "0.4.0",
"tab": { "path": "/achievements", "position": "after:analytics" }, "tab": { "path": "/achievements", "position": "after:analytics" },
"entry": "dist/index.js", "entry": "dist/index.js",
"css": "dist/style.css", "css": "dist/style.css",