mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-05-07 02:51:50 +00:00
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:
parent
297eaa3533
commit
c5789f4309
4 changed files with 332 additions and 3 deletions
|
|
@ -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
|
||||||
|
|
||||||
|
|
|
||||||
305
plugins/hermes-achievements/dashboard/dist/index.js
vendored
305
plugins/hermes-achievements/dashboard/dist/index.js
vendored
|
|
@ -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); },
|
||||||
|
})
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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; }
|
||||||
|
|
|
||||||
|
|
@ -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",
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue