feat: workflow recipes, eval badges, one-click MCP, playground upgrades, sample gallery, skill-of-the-week
Signature features that turn breadth (174 skills) into a differentiated product: - Workflow recipes: 5 cross-profession chains (workflows.json) that pass each output forward — slash commands (/ship-a-feature etc.), WORKFLOWS.md generated by scripts/build-workflows.mjs, README + MCP (list_workflows/get_workflow) wired - Eval-backed quality: real per-skill scores from evals/results.json surfaced as badges in the playground and an honest README section (6 scored skills) - One-click MCP: 'claude mcp add' install + workflow tools, works in any MCP client - Playground: 'which skill?' recommender, with/without compare toggle, shareable ?skill= deep-links with prefilled inputs - Sample-output gallery: hand-written examples for the hero five + generator (scripts/build-samples.mjs) + web/examples.html - Skill-of-the-week: scheduled workflow + script that composes X/LinkedIn posts and posts to an optional webhook Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
+192
-58
@@ -15,6 +15,11 @@ const TIER_META = {
|
||||
experimental: { label: 'Experimental', cls: 'tier-experimental', dot: '🟡' },
|
||||
};
|
||||
|
||||
// Small "eval-verified" badge for skills scored by the eval harness.
|
||||
function evalBadgeText(s) {
|
||||
return s.eval ? `✅ ${s.eval.score}/5` : '';
|
||||
}
|
||||
|
||||
init();
|
||||
|
||||
async function init() {
|
||||
@@ -46,6 +51,10 @@ async function init() {
|
||||
el('copyGemini').addEventListener('click', () => copyPrompt('gemini'));
|
||||
el('copyClaude').addEventListener('click', () => copyPrompt('claude'));
|
||||
|
||||
// "Which skill do I need?" recommender + shareable links.
|
||||
el('recommendInput').addEventListener('input', renderRecommendations);
|
||||
el('shareBtn').addEventListener('click', shareSkill);
|
||||
|
||||
try {
|
||||
const res = await fetch('skills.json');
|
||||
const data = await res.json();
|
||||
@@ -64,6 +73,84 @@ async function init() {
|
||||
sel.appendChild(o);
|
||||
}
|
||||
renderGallery();
|
||||
applyShareLink(); // open a skill (and prefill inputs) if the URL points to one
|
||||
}
|
||||
|
||||
// ---------- Recommender: rank skills by a free-text task description ----------
|
||||
function scoreSkill(s, terms) {
|
||||
const name = s.name.toLowerCase();
|
||||
const desc = (s.description || '').toLowerCase();
|
||||
const hay = (name + ' ' + desc + ' ' + (s.instructions || '')).toLowerCase();
|
||||
let score = 0;
|
||||
for (const t of terms) {
|
||||
if (name.includes(t)) score += 5;
|
||||
if (desc.includes(t)) score += 3;
|
||||
else if (hay.includes(t)) score += 1;
|
||||
}
|
||||
return score;
|
||||
}
|
||||
|
||||
function renderRecommendations() {
|
||||
const box = el('recommendResults');
|
||||
const q = el('recommendInput').value.toLowerCase().trim();
|
||||
if (q.length < 3) { box.hidden = true; box.innerHTML = ''; return; }
|
||||
const terms = [...new Set(q.split(/\s+/).filter((t) => t.length > 2))];
|
||||
const ranked = SKILLS.map((s) => ({ s, score: scoreSkill(s, terms) }))
|
||||
.filter((x) => x.score > 0)
|
||||
.sort((a, b) => b.score - a.score || (b.s.eval?.score || 0) - (a.s.eval?.score || 0))
|
||||
.slice(0, 4);
|
||||
box.innerHTML = '';
|
||||
if (!ranked.length) { box.hidden = false; box.innerHTML = '<span class="recommend-empty">No close match — try the search below.</span>'; return; }
|
||||
const lead = document.createElement('span');
|
||||
lead.className = 'recommend-lead';
|
||||
lead.textContent = 'Top matches:';
|
||||
box.appendChild(lead);
|
||||
for (const { s } of ranked) {
|
||||
const chip = document.createElement('button');
|
||||
chip.className = 'recommend-chip';
|
||||
chip.type = 'button';
|
||||
chip.textContent = s.title + (s.eval ? ` ✅ ${s.eval.score}/5` : '');
|
||||
chip.addEventListener('click', () => { el('recommendInput').value = ''; box.hidden = true; selectSkill(s); });
|
||||
box.appendChild(chip);
|
||||
}
|
||||
box.hidden = false;
|
||||
}
|
||||
|
||||
// ---------- Shareable links: ?skill=<name>&i=<base64 inputs> ----------
|
||||
function shareSkill() {
|
||||
if (!current) return;
|
||||
const fields = [...el('inputForm').querySelectorAll('input, textarea')];
|
||||
const values = fields.map((f) => f.value);
|
||||
const url = new URL(location.href.split('?')[0]);
|
||||
url.searchParams.set('skill', current.name);
|
||||
if (values.some((v) => v.trim())) {
|
||||
try {
|
||||
const packed = btoa(unescape(encodeURIComponent(JSON.stringify(values))));
|
||||
if (packed.length < 1800) url.searchParams.set('i', packed); // keep links sane
|
||||
} catch (_) { /* skip inputs if they don't encode */ }
|
||||
}
|
||||
const link = url.toString();
|
||||
navigator.clipboard.writeText(link).then(
|
||||
() => { el('shareMsg').textContent = 'Link copied — anyone who opens it lands on this skill, prefilled.'; },
|
||||
() => { el('shareMsg').textContent = link; }
|
||||
);
|
||||
}
|
||||
|
||||
function applyShareLink() {
|
||||
const params = new URLSearchParams(location.search);
|
||||
const name = params.get('skill');
|
||||
if (!name) return;
|
||||
const s = SKILLS.find((x) => x.name === name);
|
||||
if (!s) return;
|
||||
selectSkill(s);
|
||||
const packed = params.get('i');
|
||||
if (packed) {
|
||||
try {
|
||||
const values = JSON.parse(decodeURIComponent(escape(atob(packed))));
|
||||
const fields = [...el('inputForm').querySelectorAll('input, textarea')];
|
||||
fields.forEach((f, i) => { if (values[i] != null) f.value = values[i]; });
|
||||
} catch (_) { /* ignore malformed input payloads */ }
|
||||
}
|
||||
}
|
||||
|
||||
// ---------- Gallery (tiles) ----------
|
||||
@@ -94,9 +181,16 @@ function renderGallery() {
|
||||
const card = document.createElement('button');
|
||||
card.className = 'skill-card';
|
||||
card.innerHTML =
|
||||
`<div class="card-tags"><span class="card-bundle"></span><span class="card-tier"></span></div>` +
|
||||
`<div class="card-tags"><span class="card-bundle"></span><span class="card-eval"></span><span class="card-tier"></span></div>` +
|
||||
`<h3 class="card-title"></h3><p class="card-summary"></p>`;
|
||||
card.querySelector('.card-bundle').textContent = s.plugin;
|
||||
const evalEl = card.querySelector('.card-eval');
|
||||
if (s.eval) {
|
||||
evalEl.textContent = evalBadgeText(s);
|
||||
evalEl.title = `Eval-scored ${s.eval.score}/5 across ${s.eval.runs} model runs`;
|
||||
} else {
|
||||
evalEl.remove();
|
||||
}
|
||||
const tierEl = card.querySelector('.card-tier');
|
||||
tierEl.textContent = `${meta.dot} ${meta.label}`;
|
||||
tierEl.classList.add(meta.cls);
|
||||
@@ -127,12 +221,24 @@ function selectSkill(s) {
|
||||
const tierTag = el('skillTier');
|
||||
tierTag.textContent = `${meta.dot} ${meta.label}`;
|
||||
tierTag.className = 'tier-tag ' + meta.cls;
|
||||
const evalTag = el('skillEval');
|
||||
if (s.eval) {
|
||||
evalTag.textContent = `✅ Eval-scored ${s.eval.score}/5`;
|
||||
evalTag.title = `Scored ${s.eval.score}/5 by an LLM judge across ${s.eval.runs} model runs`;
|
||||
evalTag.hidden = false;
|
||||
} else {
|
||||
evalTag.hidden = true;
|
||||
}
|
||||
el('skillTitle').textContent = s.title;
|
||||
el('skillDesc').textContent = s.description;
|
||||
el('elsewhere').open = false;
|
||||
el('copyMsg').textContent = '';
|
||||
el('shareMsg').textContent = '';
|
||||
el('outputWrap').hidden = true;
|
||||
el('output').innerHTML = '';
|
||||
el('output').hidden = false;
|
||||
el('compareGrid').hidden = true;
|
||||
el('compareGrid').innerHTML = '';
|
||||
setStatus('');
|
||||
window.scrollTo({ top: 0 });
|
||||
|
||||
@@ -160,6 +266,58 @@ function makeField(inp, i) {
|
||||
return wrap;
|
||||
}
|
||||
|
||||
// Stream one completion from the API into a target node. Returns the full text.
|
||||
async function streamCompletion({ key, model, system, userMessage, node, signal }) {
|
||||
const res = await fetch(API_URL, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'content-type': 'application/json',
|
||||
'x-api-key': key,
|
||||
'anthropic-version': '2023-06-01',
|
||||
'anthropic-dangerous-direct-browser-access': 'true',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
model,
|
||||
max_tokens: 8192,
|
||||
stream: true,
|
||||
...(system ? { system } : {}),
|
||||
messages: [{ role: 'user', content: userMessage }],
|
||||
}),
|
||||
signal,
|
||||
});
|
||||
if (!res.ok) throw new Error(parseApiError(await res.text(), res.status));
|
||||
|
||||
const reader = res.body.getReader();
|
||||
const decoder = new TextDecoder();
|
||||
let buffer = '';
|
||||
let acc = '';
|
||||
while (true) {
|
||||
const { value, done } = await reader.read();
|
||||
if (done) break;
|
||||
buffer += decoder.decode(value, { stream: true });
|
||||
const lines = buffer.split('\n');
|
||||
buffer = lines.pop();
|
||||
for (const line of lines) {
|
||||
if (!line.startsWith('data:')) continue;
|
||||
const payload = line.slice(5).trim();
|
||||
if (!payload || payload === '[DONE]') continue;
|
||||
let evt;
|
||||
try { evt = JSON.parse(payload); } catch (_) { continue; }
|
||||
if (evt.type === 'content_block_delta' && evt.delta && evt.delta.text) {
|
||||
acc += evt.delta.text;
|
||||
renderMarkdown(node, acc, true);
|
||||
} else if (evt.type === 'error') {
|
||||
throw new Error(evt.error ? evt.error.message : 'Stream error from the API.');
|
||||
}
|
||||
}
|
||||
}
|
||||
renderMarkdown(node, acc, false);
|
||||
return acc;
|
||||
}
|
||||
|
||||
const SKILL_SUFFIX =
|
||||
'\n\n---\nThe user has provided their inputs below. Execute this skill now and produce the complete output. Do not ask follow-up questions — work with what is given and note any reasonable assumptions.';
|
||||
|
||||
// ---------- Run ----------
|
||||
async function run() {
|
||||
const key = el('apiKey').value.trim();
|
||||
@@ -171,74 +329,50 @@ async function run() {
|
||||
if (missing.length) return setStatus(`Fill in: ${missing.map((f) => f.dataset.label).join(', ')}`, true);
|
||||
|
||||
const userMessage = buildUserMessage(fields);
|
||||
const system = current.instructions +
|
||||
'\n\n---\nThe user has provided their inputs below. Execute this skill now and produce the complete output. Do not ask follow-up questions — work with what is given and note any reasonable assumptions.';
|
||||
const system = current.instructions + SKILL_SUFFIX;
|
||||
const model = el('model').value;
|
||||
const compare = el('compareToggle').checked;
|
||||
|
||||
const out = el('output');
|
||||
out.innerHTML = '';
|
||||
out.dataset.raw = '';
|
||||
el('outputWrap').hidden = false;
|
||||
el('runBtn').disabled = true;
|
||||
el('stopBtn').hidden = false;
|
||||
setStatus('Running…');
|
||||
|
||||
controller = new AbortController();
|
||||
|
||||
// Single mode → #output. Compare mode → two panes in #compareGrid.
|
||||
const out = el('output');
|
||||
const grid = el('compareGrid');
|
||||
let withNode, plainNode;
|
||||
if (compare) {
|
||||
out.hidden = true;
|
||||
grid.hidden = false;
|
||||
grid.innerHTML =
|
||||
'<div class="compare-pane"><div class="compare-label">✨ With the skill</div><article class="output markdown" id="paneWith"></article></div>' +
|
||||
'<div class="compare-pane"><div class="compare-label">📄 Plain prompt (no skill)</div><article class="output markdown" id="panePlain"></article></div>';
|
||||
withNode = el('paneWith');
|
||||
plainNode = el('panePlain');
|
||||
} else {
|
||||
grid.hidden = true;
|
||||
out.hidden = false;
|
||||
out.innerHTML = '';
|
||||
out.dataset.raw = '';
|
||||
}
|
||||
|
||||
let acc = '';
|
||||
try {
|
||||
const res = await fetch(API_URL, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'content-type': 'application/json',
|
||||
'x-api-key': key,
|
||||
'anthropic-version': '2023-06-01',
|
||||
'anthropic-dangerous-direct-browser-access': 'true',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
model: el('model').value,
|
||||
max_tokens: 8192,
|
||||
stream: true,
|
||||
system,
|
||||
messages: [{ role: 'user', content: userMessage }],
|
||||
}),
|
||||
signal: controller.signal,
|
||||
});
|
||||
|
||||
if (!res.ok) throw new Error(parseApiError(await res.text(), res.status));
|
||||
|
||||
const reader = res.body.getReader();
|
||||
const decoder = new TextDecoder();
|
||||
let buffer = '';
|
||||
while (true) {
|
||||
const { value, done } = await reader.read();
|
||||
if (done) break;
|
||||
buffer += decoder.decode(value, { stream: true });
|
||||
const lines = buffer.split('\n');
|
||||
buffer = lines.pop();
|
||||
for (const line of lines) {
|
||||
if (!line.startsWith('data:')) continue;
|
||||
const payload = line.slice(5).trim();
|
||||
if (!payload || payload === '[DONE]') continue;
|
||||
let evt;
|
||||
try {
|
||||
evt = JSON.parse(payload);
|
||||
} catch (_) {
|
||||
continue; // skip an unparseable / partial SSE line
|
||||
}
|
||||
if (evt.type === 'content_block_delta' && evt.delta && evt.delta.text) {
|
||||
acc += evt.delta.text;
|
||||
renderMarkdown(out, acc, true);
|
||||
} else if (evt.type === 'error') {
|
||||
throw new Error(evt.error ? evt.error.message : 'Stream error from the API.');
|
||||
}
|
||||
}
|
||||
setStatus(compare ? 'Running both…' : 'Running…');
|
||||
if (compare) {
|
||||
// Plain = the same inputs with no skill system prompt.
|
||||
[acc] = await Promise.all([
|
||||
streamCompletion({ key, model, system, userMessage, node: withNode, signal: controller.signal }),
|
||||
streamCompletion({ key, model, system: '', userMessage, node: plainNode, signal: controller.signal }),
|
||||
]);
|
||||
} else {
|
||||
acc = await streamCompletion({ key, model, system, userMessage, node: out, signal: controller.signal });
|
||||
}
|
||||
renderMarkdown(out, acc, false);
|
||||
out.dataset.raw = acc;
|
||||
out.dataset.raw = acc; // copy/download use the skill output, in either mode
|
||||
setStatus('Done.');
|
||||
} catch (e) {
|
||||
if (e.name === 'AbortError') {
|
||||
out.dataset.raw = acc;
|
||||
renderMarkdown(out, acc, false);
|
||||
setStatus('Stopped.');
|
||||
} else {
|
||||
setStatus(e.message || 'Request failed.', true);
|
||||
|
||||
@@ -20,6 +20,26 @@ const experimentalSet = new Set(TIERS.experimental);
|
||||
const tierFor = (name) =>
|
||||
productionSet.has(name) ? 'production' : experimentalSet.has(name) ? 'experimental' : 'stable';
|
||||
|
||||
// --- Eval scores (from evals/results.json) ---
|
||||
// Average the per-model "overall" (a 0–5 rubric score) for each scored skill.
|
||||
// Only skills with eval cases get a score; the rest stay unscored (honest).
|
||||
const evalScores = {};
|
||||
const evalsFile = join(root, 'evals', 'results.json');
|
||||
if (existsSync(evalsFile)) {
|
||||
try {
|
||||
const acc = {};
|
||||
for (const r of JSON.parse(readFileSync(evalsFile, 'utf8')).results || []) {
|
||||
(acc[r.skill] ||= []).push(r.overall);
|
||||
}
|
||||
for (const [skill, arr] of Object.entries(acc)) {
|
||||
evalScores[skill] = {
|
||||
score: Math.round((arr.reduce((a, b) => a + b, 0) / arr.length) * 10) / 10,
|
||||
runs: arr.length,
|
||||
};
|
||||
}
|
||||
} catch { /* leave unscored on parse error */ }
|
||||
}
|
||||
|
||||
// --- Map each skill name -> plugin bundle (for grouping/filtering) ---
|
||||
const skillToPlugin = {};
|
||||
if (existsSync(pluginsDir)) {
|
||||
@@ -109,6 +129,7 @@ for (const name of readdirSync(skillsDir)) {
|
||||
summary: summarize(meta.description || ''),
|
||||
plugin: skillToPlugin[name] || 'other',
|
||||
tier: tierFor(name),
|
||||
eval: evalScores[meta.name || name] || null,
|
||||
inputs: parseInputs(body),
|
||||
instructions: body.trim(),
|
||||
});
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Sample Outputs — see what the skills produce</title>
|
||||
<meta name="description" content="Real example outputs from the professional skill library — see exactly what each skill produces before you run it." />
|
||||
<link rel="stylesheet" href="styles.css" />
|
||||
<script src="https://cdn.jsdelivr.net/npm/marked@12.0.0/marked.min.js"></script>
|
||||
<script src="https://cdn.jsdelivr.net/npm/dompurify@3.0.9/dist/purify.min.js"></script>
|
||||
</head>
|
||||
<body>
|
||||
<header class="topbar">
|
||||
<div class="brand">
|
||||
<img src="assets/product-notes.jpg" alt="Product Notes" class="brand-logo" />
|
||||
<div class="brand-text">
|
||||
<h1>Sample Outputs</h1>
|
||||
<p class="tagline">See exactly what each skill produces — before you run anything.</p>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div class="key-note">
|
||||
📄 These are real outputs from the skills. Like one? <a href="index.html">Run it yourself in the Playground →</a>
|
||||
· 📚 <a href="catalog.html">Catalog</a> · 🏆 <a href="leaderboard.html">Leaderboard</a>
|
||||
</div>
|
||||
|
||||
<main class="main">
|
||||
<div class="recommend" style="margin-bottom:18px">
|
||||
<input id="filter" type="search" placeholder="Filter samples…" />
|
||||
</div>
|
||||
<section id="samples"></section>
|
||||
</main>
|
||||
|
||||
<script>
|
||||
const elx = (id) => document.getElementById(id);
|
||||
let SAMPLES = [];
|
||||
|
||||
async function init() {
|
||||
try {
|
||||
SAMPLES = (await (await fetch('samples.json')).json()).samples;
|
||||
} catch (e) {
|
||||
elx('samples').innerHTML = '<p class="empty-msg">Could not load samples.json. Run <code>node scripts/build-samples.mjs</code>.</p>';
|
||||
return;
|
||||
}
|
||||
elx('filter').addEventListener('input', render);
|
||||
render();
|
||||
}
|
||||
|
||||
function render() {
|
||||
const q = elx('filter').value.toLowerCase().trim();
|
||||
const box = elx('samples');
|
||||
box.innerHTML = '';
|
||||
const list = SAMPLES.filter((s) => !q || (s.title + ' ' + s.skill + ' ' + s.input).toLowerCase().includes(q));
|
||||
if (!list.length) { box.innerHTML = '<p class="empty-msg">No samples match.</p>'; return; }
|
||||
for (const s of list) {
|
||||
const card = document.createElement('details');
|
||||
card.className = 'sample-card';
|
||||
card.open = list.length <= 2;
|
||||
const note = s.source ? `<span class="sample-source">${s.source}</span>` : '';
|
||||
card.innerHTML =
|
||||
`<summary><span class="sample-title">${s.title}</span> ${note}` +
|
||||
`<a class="sample-run" href="index.html?skill=${encodeURIComponent(s.skill)}" onclick="event.stopPropagation()">Run this →</a></summary>` +
|
||||
`<p class="sample-input"><strong>Input:</strong> ${escapeHtml(s.input)}</p>` +
|
||||
`<div class="output markdown sample-output"></div>`;
|
||||
card.querySelector('.sample-output').innerHTML = DOMPurify.sanitize(marked.parse(s.output));
|
||||
box.appendChild(card);
|
||||
}
|
||||
}
|
||||
|
||||
function escapeHtml(s) {
|
||||
return String(s).replace(/[&<>"']/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c]));
|
||||
}
|
||||
|
||||
init();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
+11
-1
@@ -34,7 +34,12 @@
|
||||
<div class="key-note">
|
||||
🔒 Your key is stored only in this browser and sent directly to api.anthropic.com — never to us.
|
||||
Get one at <a href="https://console.anthropic.com/settings/keys" target="_blank" rel="noopener">console.anthropic.com</a>.
|
||||
· 📚 <a href="catalog.html">Catalog</a> · 🏆 <a href="leaderboard.html">Leaderboard</a>
|
||||
· 📄 <a href="examples.html">Sample outputs</a> · 📚 <a href="catalog.html">Catalog</a> · 🏆 <a href="leaderboard.html">Leaderboard</a>
|
||||
</div>
|
||||
|
||||
<div class="recommend" id="recommend">
|
||||
<input id="recommendInput" type="text" placeholder="🧭 Not sure which skill? Describe your task — e.g. “explain a metric drop to my CEO”" />
|
||||
<div id="recommendResults" class="recommend-results" hidden></div>
|
||||
</div>
|
||||
|
||||
<div class="controls" id="controls">
|
||||
@@ -60,8 +65,11 @@
|
||||
<div class="skill-head">
|
||||
<span class="bundle-tag" id="skillBundle"></span>
|
||||
<span class="tier-tag" id="skillTier"></span>
|
||||
<span class="eval-badge" id="skillEval" hidden></span>
|
||||
<h2 id="skillTitle"></h2>
|
||||
<p id="skillDesc" class="skill-desc"></p>
|
||||
<button id="shareBtn" class="ghost share-btn" type="button" title="Copy a link that opens this skill with these inputs">🔗 Share</button>
|
||||
<span id="shareMsg" class="copy-msg"></span>
|
||||
</div>
|
||||
|
||||
<details class="elsewhere" id="elsewhere">
|
||||
@@ -80,6 +88,7 @@
|
||||
<div class="actions">
|
||||
<button id="runBtn" class="primary" type="button">Run with my Claude key</button>
|
||||
<button id="stopBtn" class="ghost" type="button" hidden>Stop</button>
|
||||
<label class="compare-toggle" title="Run the same inputs with and without the skill, side by side"><input type="checkbox" id="compareToggle" /> ⚖️ Compare vs. plain prompt</label>
|
||||
<span id="status" class="status"></span>
|
||||
</div>
|
||||
|
||||
@@ -92,6 +101,7 @@
|
||||
</div>
|
||||
</div>
|
||||
<article id="output" class="output markdown"></article>
|
||||
<div id="compareGrid" class="compare-grid" hidden></div>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"week": 2946,
|
||||
"skill": "retention-analysis",
|
||||
"title": "Retention Analysis",
|
||||
"summary": "Structure a retention analysis, churn investigation, or engagement deep-dive for any product team.",
|
||||
"link": "https://mohitagw15856.github.io/pm-claude-skills/index.html?skill=retention-analysis",
|
||||
"generatedAt": "2026-06-19T08:52:42.226Z",
|
||||
"posts": {
|
||||
"x": "🛠️ Skill of the week: Retention Analysis\n\nStructure a retention analysis, churn investigation, or engagement deep-dive for any product team.\n\nRun it free in your browser (no install) 👇\nhttps://mohitagw15856.github.io/pm-claude-skills/index.html?skill=retention-analysis\n\n#ClaudeAI #AItools #ProductManagement",
|
||||
"linkedin": "🛠️ Skill of the week: Retention Analysis\n\nStructure a retention analysis, churn investigation, or engagement deep-dive for any product team.\n\nIt's one of 174 open-source AI skills that make Claude, ChatGPT, and Gemini produce real professional work. Try this one free in the browser — no install:\nhttps://mohitagw15856.github.io/pm-claude-skills/index.html?skill=retention-analysis\n\n⭐ https://github.com/mohitagw15856/pm-claude-skills"
|
||||
}
|
||||
}
|
||||
+1
-1
File diff suppressed because one or more lines are too long
@@ -187,8 +187,65 @@ button.ghost:hover { border-color: var(--accent); }
|
||||
.cursor::after { content: "▍"; color: var(--accent); animation: blink 1s steps(2) infinite; }
|
||||
@keyframes blink { 50% { opacity: 0; } }
|
||||
|
||||
/* ---------- Recommender ---------- */
|
||||
.recommend { max-width: 1100px; margin: 18px auto 0; padding: 0 4px; }
|
||||
.recommend > input {
|
||||
width: 100%; box-sizing: border-box; padding: 12px 14px; font-size: 14px;
|
||||
background: var(--panel); color: var(--text);
|
||||
border: 1px solid var(--border); border-radius: 10px;
|
||||
}
|
||||
.recommend > input:focus { outline: none; border-color: var(--accent); }
|
||||
.recommend-results {
|
||||
display: flex; flex-wrap: wrap; align-items: center; gap: 8px; margin-top: 10px;
|
||||
}
|
||||
.recommend-lead { font-size: 12.5px; color: var(--muted); }
|
||||
.recommend-empty { font-size: 12.5px; color: var(--muted); }
|
||||
.recommend-chip {
|
||||
font-size: 12.5px; padding: 6px 11px; border-radius: 99px; cursor: pointer;
|
||||
background: rgba(217,119,87,.12); color: var(--accent-2);
|
||||
border: 1px solid rgba(217,119,87,.35);
|
||||
}
|
||||
.recommend-chip:hover { background: rgba(217,119,87,.22); }
|
||||
|
||||
/* ---------- Eval badges ---------- */
|
||||
.card-eval, .eval-badge {
|
||||
font-size: 10px; font-weight: 700; letter-spacing: .02em; padding: 2px 7px;
|
||||
border-radius: 99px; white-space: nowrap;
|
||||
color: #6ee7b7; background: rgba(16,185,129,.12); border: 1px solid rgba(16,185,129,.35);
|
||||
}
|
||||
.eval-badge { display: inline-block; font-size: 11px; margin: 0 0 8px 6px; }
|
||||
|
||||
/* ---------- Share button ---------- */
|
||||
.share-btn { margin-top: 10px; }
|
||||
|
||||
/* ---------- Sample-output gallery ---------- */
|
||||
.sample-card {
|
||||
max-width: 880px; margin: 0 auto 14px; background: var(--panel);
|
||||
border: 1px solid var(--border); border-radius: 12px; padding: 4px 18px;
|
||||
}
|
||||
.sample-card summary {
|
||||
cursor: pointer; padding: 12px 0; display: flex; align-items: center; gap: 10px; flex-wrap: wrap;
|
||||
}
|
||||
.sample-title { font-size: 15px; font-weight: 600; }
|
||||
.sample-source { font-size: 11px; color: var(--muted); }
|
||||
.sample-run { margin-left: auto; font-size: 12.5px; color: var(--accent-2); text-decoration: none; }
|
||||
.sample-run:hover { text-decoration: underline; }
|
||||
.sample-input { font-size: 12.5px; color: var(--muted); line-height: 1.5; margin: 0 0 8px; }
|
||||
.sample-output { border-top: 1px solid var(--border); padding-top: 12px; }
|
||||
|
||||
/* ---------- Compare mode ---------- */
|
||||
.compare-toggle { font-size: 12.5px; color: var(--muted); display: inline-flex; align-items: center; gap: 6px; cursor: pointer; }
|
||||
.compare-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 16px; }
|
||||
.compare-pane { min-width: 0; border: 1px solid var(--border); border-radius: 10px; overflow: hidden; }
|
||||
.compare-label {
|
||||
font-size: 12px; font-weight: 600; padding: 8px 12px;
|
||||
background: var(--panel-2); border-bottom: 1px solid var(--border);
|
||||
}
|
||||
.compare-pane .output { padding: 12px 14px; }
|
||||
|
||||
@media (max-width: 620px) {
|
||||
.key-field input { width: 200px; }
|
||||
.brand-text h1 { font-size: 15px; }
|
||||
.gallery { grid-template-columns: 1fr; }
|
||||
.compare-grid { grid-template-columns: 1fr; }
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user