Compare commits

..

8 Commits

Author SHA1 Message Date
mohitagw15856 2299e59d72 Merge pull request #21 from mohitagw15856/add-skill-playground-web-ui
fix(web): propagate mid-stream API errors and raise max_tokens
2026-06-09 12:12:47 +01:00
Mohit 5721cd3a49 fix(web): propagate mid-stream API errors and raise max_tokens
- Streaming loop swallowed errors: a mid-stream error event (e.g.
  overloaded_error) was thrown inside the same try/catch used to skip
  unparseable SSE lines, so it was silently ignored and the run reported
  "Done." with truncated output. Separate JSON parsing from event handling
  so real errors surface to the user.
- Raise max_tokens 4096 -> 8192 to avoid truncating long skill outputs.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-09 12:07:18 +01:00
mohitagw15856 f0c77f634e Merge pull request #20 from mohitagw15856/add-skill-playground-web-ui
Add Skill Playground — browser UI to run any skill with your own Claude key
2026-06-09 12:03:18 +01:00
Mohit 735df19a9b docs(readme): add live GitHub Pages link to Skill Playground section
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-09 12:02:31 +01:00
Mohit f956b4c329 ci: auto-deploy Skill Playground to GitHub Pages
On push to main, rebuild web/skills.json from the SKILL.md files and publish
web/ to GitHub Pages, so the live site always reflects the current skill
library. Manual runs supported via workflow_dispatch.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-09 12:01:09 +01:00
Mohit 2e58766814 feat(web): add Skill Playground — browser UI to run any skill with your own key
A zero-backend static web app to run any of the 172 skills directly in the
browser using the user's own Claude API key (stored only in localStorage,
sent straight to api.anthropic.com).

- build-skills.mjs: generates skills.json from skills/*/SKILL.md, parsing
  frontmatter, the Required Inputs section (-> form fields), and a one-line
  summary for each skill tile.
- Tile gallery with bundle tag, title, and one-line description; search +
  bundle filter; click a tile to open an auto-generated input form.
- Streams output via the Anthropic Messages API (direct browser access),
  with copy/download, model picker, and Show/Hide key toggle.
- Product Notes logo in the header.
- README: add Skill Playground section + screenshot, a table of contents,
  and collapse the long changelog and full skills list into <details> blocks.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-09 11:58:59 +01:00
mohitagw15856 bd7d5afce1 Merge pull request #19 from mohitagw15856/claude/admiring-cori-murZN
Update read me
2026-06-08 14:45:21 +01:00
mohitagw15856 5d4d007aeb Merge pull request #18 from mohitagw15856/claude/admiring-cori-murZN
Quality improvements of skills - Anti-Patterns section, Description verb-when-produces, Required Inputs section, Quality Checks binary format, Frontmatter YAML
2026-06-08 14:07:56 +01:00
10 changed files with 778 additions and 5 deletions
+58
View File
@@ -0,0 +1,58 @@
name: Deploy Skill Playground
# Rebuilds web/skills.json from the SKILL.md files and publishes web/ to
# GitHub Pages. Runs on every push to main that touches skills or the web app,
# so the live site always reflects the current skill library.
on:
push:
branches: [main]
paths:
- 'skills/**'
- 'web/**'
- '.github/workflows/deploy-playground.yml'
workflow_dispatch:
permissions:
contents: read
pages: write
id-token: write
# Allow one concurrent deployment; cancel in-progress runs for the same ref.
concurrency:
group: pages
cancel-in-progress: true
jobs:
build:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Set up Node
uses: actions/setup-node@v4
with:
node-version: '20'
- name: Rebuild skills.json from SKILL.md files
run: node web/build-skills.mjs
- name: Configure Pages
uses: actions/configure-pages@v5
- name: Upload web/ as Pages artifact
uses: actions/upload-pages-artifact@v3
with:
path: web
deploy:
needs: build
runs-on: ubuntu-latest
environment:
name: github-pages
url: ${{ steps.deployment.outputs.page_url }}
steps:
- name: Deploy to GitHub Pages
id: deployment
uses: actions/deploy-pages@v4
+53 -5
View File
@@ -15,6 +15,18 @@ A community-built library of Claude Skills for professionals across every field
**🆕 Latest release (v14.0.0):** 12 new community-inspired skills across 4 bundles — a brand new Writers & Content Creators profession (Instagram downloader, AEO optimizer, thumbnail creator, Substack scraper, notes humanizer), plus decision-making, productivity, and Claude Code power tools.
---
## Contents
- [🚀 Quick Install](#-quick-install-2-minutes)
- [🌐 Skill Playground — try any skill in your browser](#-skill-playground--try-any-skill-in-your-browser)
- [📦 Plugin Directory](#-plugin-directory)
- [🤖 Building Blocks for Agent Templates](#-building-blocks-for-agent-templates)
- [🗂️ All 167 Skills](#-all-167-skills)
- [📋 Changelog](#-changelog)
- [🤝 Contributing](#-contributing--add-your-skill)
---
## 🚀 Quick Install (2 minutes)
In Claude Code, run:
@@ -62,6 +74,28 @@ ln -s ~/pm-claude-skills/skills/* ~/.claude/skills/
---
## 🌐 Skill Playground — Try Any Skill in Your Browser
**▶ Live: [mohitagw15856.github.io/pm-claude-skills](https://mohitagw15856.github.io/pm-claude-skills/)**
Don't want to install anything yet? Run any of these skills from a **zero-backend web app** using **your own Claude API key**. Pick a skill, fill in the auto-generated form, and Claude streams the result. Your key is stored only in your browser (`localStorage`) and sent directly to the Anthropic API — nothing touches a server we own.
![Skill Playground — pick a skill, fill the form, run it with your own Claude key](web/docs-assets/playground.png)
**Run it locally:**
```bash
git clone https://github.com/mohitagw15856/pm-claude-skills.git
cd pm-claude-skills
node web/build-skills.mjs # generate the skill index (skills.json)
cd web && python3 -m http.server 8000 # serve over HTTP (not file://)
# open http://localhost:8000 and paste a key from console.anthropic.com
```
It's fully static — deploy the `web/` folder to GitHub Pages, Netlify, or Vercel with no environment variables. Full details in [`web/README.md`](web/README.md).
---
## 📦 Plugin Directory
Not sure which plugin to install? Here's what each one covers:
@@ -189,7 +223,12 @@ More templates will follow. If you want to contribute one, see the [template con
---
## 🆕 What's New in v14.0.0 — Writers & Content Creators + 7 Community Skills
## 📋 Changelog
<details>
<summary><strong>Release history — v6.0.0 → v14.0.0</strong> (click to expand)</summary>
### 🆕 What's New in v14.0.0 — Writers & Content Creators + 7 Community Skills
**12 new community-inspired skills across 4 bundles:**
@@ -229,7 +268,7 @@ The library now includes **167 skills** across **18 professions** + 4 working ag
---
## 🆕 What's New in v13.0.0 — Social Media Profession
### 🆕 What's New in v13.0.0 — Social Media Profession
**5 new skills — a complete Social Media profession bundle:**
@@ -250,7 +289,7 @@ claude plugin install pm-social@pm-claude-skills
---
## 🆕 What's New in v12.0.0 — 150 Skills Milestone
### 🆕 What's New in v12.0.0 — 150 Skills Milestone
**15 new skills across 10 bundles:**
@@ -276,7 +315,7 @@ The library now includes **150 skills** across **16 professions** + 4 working ag
---
## 🆕 What's New in v10.0.0
### 🆕 What's New in v10.0.0
**Two star milestones unlocked — 8 new skills shipped:**
@@ -316,7 +355,7 @@ The `pm-engineering` bundle now has **10 skills** — the most complete engineer
---
## 📖 v6.0.0 — 100 Skills Milestone
### 📖 v6.0.0 — 100 Skills Milestone
**7 skills added:**
@@ -330,6 +369,8 @@ The `pm-engineering` bundle now has **10 skills** — the most complete engineer
| **Sales Forecasting Model** | pm-sales | Pipeline-based forecast with stage model, scenario analysis, assumption log, and activity sanity check |
| **Tax Planning Checklist** | pm-finance | Year-end tax planning review framework across income, pension, CGT, business reliefs, and ISAs |
</details>
---
## 📚 The Article Series
@@ -359,6 +400,11 @@ This repo was built alongside a published article series. Read the full story:
## 🗂️ All 167 Skills
The [Plugin Directory](#-plugin-directory) above summarises every bundle. Expand below for the full per-skill breakdown with folder paths.
<details>
<summary><strong>Browse all 167 skills by profession</strong> (click to expand)</summary>
### 🛠️ Product Management (Skills 137)
**Bundles:** `pm-essentials` · `pm-discovery` · `pm-planning` · `pm-delivery` · `pm-analytics` · `pm-strategy` · `pm-advanced` · `pm-rituals`
@@ -674,6 +720,8 @@ claude plugin install pm-writers@pm-claude-skills
| 159 | **Substack Notes Scraper** 🆕 | `skills/substack-notes-scraper/` | Scrapes Substack Notes and exports likes, comments, and restacks to a formatted .xlsx with frozen headers, filters, and top-performer highlighting |
| 160 | **Notes Humanizer** 🆕 | `skills/notes-humanizer/` | Strips AI writing patterns (em dashes, filler phrases, uniform rhythm) across 3 phases: audit, strip, inject — returns side-by-side comparison and clean final text |
</details>
---
## ❤️ Sponsor This Work
+36
View File
@@ -0,0 +1,36 @@
# Skill Playground
A zero-backend web app to run any skill in this repo with **your own Claude API key**.
Pick a skill → it becomes a form → fill it in → Claude executes the skill's instructions
and streams the result. Your key is stored only in your browser (`localStorage`) and sent
directly to `api.anthropic.com`. Nothing touches a server we own.
## Run locally
```bash
node web/build-skills.mjs # regenerate skills.json from skills/
cd web && python3 -m http.server 8000
# open http://localhost:8000
```
> It must be served over HTTP (not opened as a `file://` URL) so `fetch('skills.json')` works.
Paste a key from [console.anthropic.com](https://console.anthropic.com/settings/keys) and run.
## How it works
- `build-skills.mjs` scans `../skills/*/SKILL.md`, parses the frontmatter and the
**Required Inputs** section, and writes `skills.json` (the UI's data source).
- `app.js` sends the skill's instruction body as the `system` prompt and the filled-in
fields as the user message, using the Anthropic Messages API with
`anthropic-dangerous-direct-browser-access: true` for direct browser calls.
## Keep it in sync
Re-run `node web/build-skills.mjs` whenever skills are added or edited, and commit the
updated `skills.json`. (Or wire it into CI / a pre-commit hook.)
## Deploy
It's fully static — host the `web/` folder on GitHub Pages, Netlify, Vercel, or any
static host. No environment variables, no server.
+272
View File
@@ -0,0 +1,272 @@
'use strict';
const KEY_STORE = 'anthropic_api_key';
const MODEL_STORE = 'anthropic_model';
const API_URL = 'https://api.anthropic.com/v1/messages';
const el = (id) => document.getElementById(id);
let SKILLS = [];
let current = null;
let controller = null;
init();
async function init() {
const savedKey = localStorage.getItem(KEY_STORE);
if (savedKey) el('apiKey').value = savedKey;
const savedModel = localStorage.getItem(MODEL_STORE);
if (savedModel) el('model').value = savedModel;
el('apiKey').addEventListener('input', (e) => localStorage.setItem(KEY_STORE, e.target.value.trim()));
el('model').addEventListener('change', (e) => localStorage.setItem(MODEL_STORE, e.target.value));
el('keyToggle').addEventListener('click', () => {
const f = el('apiKey');
const show = f.type === 'password';
f.type = show ? 'text' : 'password';
el('keyToggle').textContent = show ? 'Hide' : 'Show';
});
el('search').addEventListener('input', renderGallery);
el('pluginFilter').addEventListener('change', renderGallery);
el('backBtn').addEventListener('click', showGallery);
el('runBtn').addEventListener('click', run);
el('stopBtn').addEventListener('click', () => controller && controller.abort());
el('copyBtn').addEventListener('click', () => navigator.clipboard.writeText(el('output').dataset.raw || ''));
el('downloadBtn').addEventListener('click', downloadOutput);
try {
const res = await fetch('skills.json');
const data = await res.json();
SKILLS = data.skills;
} catch (e) {
el('gallery').innerHTML = '<p class="empty-msg">Could not load skills.json. Run <code>node web/build-skills.mjs</code> and serve this folder over HTTP.</p>';
return;
}
const bundles = [...new Set(SKILLS.map((s) => s.plugin))].sort();
const sel = el('pluginFilter');
for (const b of bundles) {
const o = document.createElement('option');
o.value = b;
o.textContent = b;
sel.appendChild(o);
}
renderGallery();
}
// ---------- Gallery (tiles) ----------
function renderGallery() {
const q = el('search').value.toLowerCase().trim();
const bundle = el('pluginFilter').value;
const gallery = el('gallery');
gallery.innerHTML = '';
const matches = SKILLS.filter((s) => {
if (bundle && s.plugin !== bundle) return false;
if (!q) return true;
return (s.title + ' ' + s.description + ' ' + s.name).toLowerCase().includes(q);
});
el('count').textContent = `${matches.length} skill${matches.length === 1 ? '' : 's'}`;
if (!matches.length) {
gallery.innerHTML = '<p class="empty-msg">No skills match your search.</p>';
return;
}
const frag = document.createDocumentFragment();
for (const s of matches) {
const card = document.createElement('button');
card.className = 'skill-card';
card.innerHTML =
`<span class="card-bundle"></span><h3 class="card-title"></h3><p class="card-summary"></p>`;
card.querySelector('.card-bundle').textContent = s.plugin;
card.querySelector('.card-title').textContent = s.title;
card.querySelector('.card-summary').textContent = s.summary || s.description;
card.addEventListener('click', () => selectSkill(s));
frag.appendChild(card);
}
gallery.appendChild(frag);
}
function showGallery() {
current = null;
el('runner').hidden = true;
el('gallery').hidden = false;
el('controls').hidden = false;
window.scrollTo({ top: 0 });
}
// ---------- Select & build form ----------
function selectSkill(s) {
current = s;
el('gallery').hidden = true;
el('controls').hidden = true;
el('runner').hidden = false;
el('skillBundle').textContent = s.plugin;
el('skillTitle').textContent = s.title;
el('skillDesc').textContent = s.description;
el('outputWrap').hidden = true;
el('output').innerHTML = '';
setStatus('');
window.scrollTo({ top: 0 });
const form = el('inputForm');
form.innerHTML = '';
if (!s.inputs.length) {
form.appendChild(makeField({ label: 'Your input / context', hint: 'Describe what you need. This skill did not declare structured inputs.', long: true }, 0));
} else {
s.inputs.forEach((inp, i) => form.appendChild(makeField(inp, i)));
}
}
function makeField(inp, i) {
const wrap = document.createElement('div');
wrap.className = 'field';
const id = 'f_' + i;
const opt = inp.optional ? ' <span class="opt">(optional)</span>' : '';
const hint = inp.hint ? ` <span class="hint">— ${escapeHtml(inp.hint)}</span>` : '';
wrap.innerHTML = `<label for="${id}">${escapeHtml(inp.label)}${opt}${hint}</label>`;
const input = inp.long ? document.createElement('textarea') : document.createElement('input');
input.id = id;
input.dataset.label = inp.label;
input.dataset.optional = inp.optional ? '1' : '';
wrap.appendChild(input);
return wrap;
}
// ---------- Run ----------
async function run() {
const key = el('apiKey').value.trim();
if (!key) return setStatus('Enter your Claude API key first.', true);
if (!current) return;
const fields = [...el('inputForm').querySelectorAll('input, textarea')];
const missing = fields.filter((f) => !f.dataset.optional && !f.value.trim());
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 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();
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.');
}
}
}
renderMarkdown(out, acc, false);
out.dataset.raw = acc;
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);
}
} finally {
el('runBtn').disabled = false;
el('stopBtn').hidden = true;
controller = null;
}
}
function buildUserMessage(fields) {
return fields
.filter((f) => f.value.trim())
.map((f) => `## ${f.dataset.label}\n${f.value.trim()}`)
.join('\n\n');
}
function renderMarkdown(node, text, streaming) {
node.innerHTML = DOMPurify.sanitize(marked.parse(text, { breaks: true }));
node.classList.toggle('cursor', streaming);
}
function downloadOutput() {
const raw = el('output').dataset.raw || '';
if (!raw) return;
const blob = new Blob([raw], { type: 'text/markdown' });
const a = document.createElement('a');
a.href = URL.createObjectURL(blob);
a.download = `${current.name}-output.md`;
a.click();
URL.revokeObjectURL(a.href);
}
// ---------- Helpers ----------
function setStatus(msg, isErr) {
const s = el('status');
s.textContent = msg;
s.className = 'status' + (isErr ? ' err' : '');
}
function parseApiError(text, status) {
try {
const j = JSON.parse(text);
if (j.error && j.error.message) {
if (status === 401) return 'Invalid API key (401). Check the key and try again.';
if (status === 429) return 'Rate limit or insufficient credits (429): ' + j.error.message;
return `API error ${status}: ${j.error.message}`;
}
} catch (_) {}
return `Request failed (${status}).`;
}
function escapeHtml(s) {
return String(s).replace(/[&<>"']/g, (c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[c]));
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 50 KiB

+109
View File
@@ -0,0 +1,109 @@
#!/usr/bin/env node
// Generates web/skills.json from the canonical skills/ directory.
// No dependencies — run with: node web/build-skills.mjs
import { readFileSync, readdirSync, writeFileSync, existsSync, statSync } from 'node:fs';
import { join, dirname } from 'node:path';
import { fileURLToPath } from 'node:url';
const __dirname = dirname(fileURLToPath(import.meta.url));
const root = join(__dirname, '..');
const skillsDir = join(root, 'skills');
const pluginsDir = join(root, 'plugins');
// --- Map each skill name -> plugin bundle (for grouping/filtering) ---
const skillToPlugin = {};
if (existsSync(pluginsDir)) {
for (const plugin of readdirSync(pluginsDir)) {
const pSkills = join(pluginsDir, plugin, 'skills');
if (existsSync(pSkills) && statSync(pSkills).isDirectory()) {
for (const s of readdirSync(pSkills)) skillToPlugin[s] = plugin;
}
}
}
// --- Parse YAML-ish frontmatter (name + description only) ---
function parseFrontmatter(text) {
const m = text.match(/^---\n([\s\S]*?)\n---\n?([\s\S]*)$/);
if (!m) return { meta: {}, body: text };
const meta = {};
for (const line of m[1].split('\n')) {
const kv = line.match(/^(\w[\w-]*):\s*(.*)$/);
if (kv) {
let v = kv[2].trim();
if ((v.startsWith('"') && v.endsWith('"')) || (v.startsWith("'") && v.endsWith("'"))) {
v = v.slice(1, -1);
}
meta[kv[1]] = v;
}
}
return { meta, body: m[2] };
}
// --- Extract input fields from the "Required Inputs" style section ---
function parseInputs(body) {
const lines = body.split('\n');
const headingRe = /^#{2,3}\s+.*(required inputs|inputs needed|information needed|what (i|you).*need)/i;
let i = lines.findIndex((l) => headingRe.test(l));
if (i === -1) return [];
const inputs = [];
for (let j = i + 1; j < lines.length; j++) {
const line = lines[j];
if (/^#{1,3}\s/.test(line)) break; // next section
const bullet = line.match(/^\s*[-*]\s+(.*)$/);
if (!bullet) continue;
const content = bullet[1];
const boldMatch = content.match(/\*\*(.+?)\*\*/);
if (!boldMatch) continue;
let label = boldMatch[1].replace(/\s*\/\s*/g, ' / ').trim();
// hint = remainder after the first bold label
let rest = content.replace(/\*\*(.+?)\*\*/, '').replace(/^[\s—:-]+/, '').trim();
rest = rest.replace(/\*\*/g, '').replace(/^\((.*)\)$/, '$1').trim();
const optional = /optional/i.test(content);
const long = /notes|description|summary|data|what happened|details|paste|context/i.test(
label + ' ' + rest
);
inputs.push({ label, hint: rest, optional, long });
}
return inputs;
}
// First sentence of the description, trimmed — used as the tile one-liner.
function summarize(desc) {
if (!desc) return '';
let first = desc.split(/(?<=\.)\s+/)[0].trim();
// If the first sentence is just a trigger ("Use when…"), fall back to the whole thing.
if (/^use\b/i.test(first) && desc.length > first.length) first = desc;
first = first.replace(/\s+/g, ' ').trim();
if (first.length > 150) first = first.slice(0, 147).replace(/[\s,;:]+\S*$/, '') + '…';
return first;
}
function titleFromName(name) {
return name
.split('-')
.map((w) => w.charAt(0).toUpperCase() + w.slice(1))
.join(' ');
}
const skills = [];
for (const name of readdirSync(skillsDir)) {
const file = join(skillsDir, name, 'SKILL.md');
if (!existsSync(file)) continue;
const text = readFileSync(file, 'utf8');
const { meta, body } = parseFrontmatter(text);
const titleHeading = body.match(/^#\s+(.+)$/m);
skills.push({
name: meta.name || name,
title: (titleHeading ? titleHeading[1] : titleFromName(meta.name || name)).replace(/\s+Skill$/i, ''),
description: meta.description || '',
summary: summarize(meta.description || ''),
plugin: skillToPlugin[name] || 'other',
inputs: parseInputs(body),
instructions: body.trim(),
});
}
skills.sort((a, b) => a.title.localeCompare(b.title));
const out = { generatedAt: new Date().toISOString(), count: skills.length, skills };
writeFileSync(join(__dirname, 'skills.json'), JSON.stringify(out));
console.log(`Wrote web/skills.json — ${skills.length} skills, ${Object.keys(skillToPlugin).length ? new Set(skills.map(s=>s.plugin)).size : 0} bundles.`);
Binary file not shown.

After

Width:  |  Height:  |  Size: 497 KiB

+82
View File
@@ -0,0 +1,82 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Skill Playground — Product Notes</title>
<meta name="description" content="Run any of 170+ Claude skills from your browser with your own API key. Pick a skill tile, fill the inputs, get the output." />
<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>Skill Playground</h1>
<p class="tagline">170+ Claude skills — pick one and run it with your own key.</p>
</div>
</div>
<div class="key-area">
<div class="key-field">
<input id="apiKey" type="password" placeholder="sk-ant-… (your Claude API key)" autocomplete="off" spellcheck="false" />
<button id="keyToggle" type="button" class="show-toggle">Show</button>
</div>
<select id="model" title="Model">
<option value="claude-opus-4-8">Opus 4.8</option>
<option value="claude-sonnet-4-6" selected>Sonnet 4.6</option>
<option value="claude-haiku-4-5-20251001">Haiku 4.5</option>
</select>
</div>
</header>
<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>.
</div>
<div class="controls" id="controls">
<input id="search" type="search" placeholder="Search skills…" />
<select id="pluginFilter"><option value="">All bundles</option></select>
<span id="count" class="count"></span>
</div>
<main class="main">
<!-- Tile gallery -->
<section id="gallery" class="gallery"></section>
<!-- Single-skill runner -->
<section id="runner" class="runner" hidden>
<button id="backBtn" class="back" type="button">← All skills</button>
<div class="skill-head">
<span class="bundle-tag" id="skillBundle"></span>
<h2 id="skillTitle"></h2>
<p id="skillDesc" class="skill-desc"></p>
</div>
<form id="inputForm" class="input-form"></form>
<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>
<span id="status" class="status"></span>
</div>
<div class="output-wrap" id="outputWrap" hidden>
<div class="output-toolbar">
<span>Output</span>
<div>
<button id="copyBtn" class="ghost" type="button">Copy</button>
<button id="downloadBtn" class="ghost" type="button">Download .md</button>
</div>
</div>
<article id="output" class="output markdown"></article>
</div>
</section>
</main>
<script src="app.js"></script>
</body>
</html>
+1
View File
File diff suppressed because one or more lines are too long
+167
View File
@@ -0,0 +1,167 @@
:root {
--bg: #0f1115;
--panel: #161a21;
--panel-2: #1d222b;
--border: #2a313c;
--text: #e7ebf0;
--muted: #95a0b0;
--accent: #d97757;
--accent-2: #e89b82;
--radius: 12px;
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Inter, Roboto, sans-serif;
}
* { box-sizing: border-box; }
[hidden] { display: none !important; } /* beat .gallery/.controls display rules */
html, body { margin: 0; height: 100%; }
body {
background: var(--bg);
color: var(--text);
display: flex;
flex-direction: column;
min-height: 100vh;
}
a { color: var(--accent-2); }
/* ---------- Topbar ---------- */
.topbar {
display: flex; align-items: center; justify-content: space-between;
gap: 16px; padding: 12px 22px; background: var(--panel);
border-bottom: 1px solid var(--border); flex-wrap: wrap;
}
.brand { display: flex; align-items: center; gap: 14px; }
.brand-logo {
height: 46px; width: auto; border-radius: 8px; background: #f5f1e8;
padding: 4px 8px; display: block;
}
.brand-text h1 { font-size: 17px; margin: 0; }
.tagline { margin: 2px 0 0; font-size: 12px; color: var(--muted); }
.key-area { display: flex; gap: 8px; align-items: center; }
.key-field { position: relative; display: flex; align-items: center; }
.key-field input {
width: 330px; max-width: 44vw; padding: 9px 58px 9px 12px;
background: var(--panel-2); border: 1px solid var(--border);
border-radius: 8px; color: var(--text); font-size: 13px;
}
.key-field input:focus { outline: none; border-color: var(--accent); }
.show-toggle {
position: absolute; right: 5px; border: none; background: transparent;
color: var(--accent-2); font-size: 12px; font-weight: 600; cursor: pointer; padding: 4px 6px;
}
.key-area select {
padding: 9px 10px; background: var(--panel-2); border: 1px solid var(--border);
border-radius: 8px; color: var(--text); font-size: 13px;
}
.key-note {
font-size: 12px; color: var(--muted); padding: 8px 22px;
background: var(--panel); border-bottom: 1px solid var(--border);
}
/* ---------- Controls ---------- */
.controls {
display: flex; gap: 10px; align-items: center; padding: 14px 22px;
position: sticky; top: 0; background: var(--bg); z-index: 5;
border-bottom: 1px solid var(--border);
}
.controls input, .controls select {
padding: 9px 12px; background: var(--panel-2); border: 1px solid var(--border);
border-radius: 8px; color: var(--text); font-size: 13px;
}
.controls input { flex: 1; max-width: 460px; }
.controls input:focus, .controls select:focus { outline: none; border-color: var(--accent); }
.count { color: var(--muted); font-size: 12px; margin-left: auto; }
/* ---------- Main / gallery ---------- */
.main { flex: 1; padding: 22px; }
.gallery {
display: grid; gap: 14px;
grid-template-columns: repeat(auto-fill, minmax(265px, 1fr));
}
.skill-card {
text-align: left; background: var(--panel); border: 1px solid var(--border);
border-radius: var(--radius); padding: 16px 16px 18px; cursor: pointer;
color: var(--text); display: flex; flex-direction: column; gap: 7px;
transition: border-color .12s, transform .12s, background .12s;
}
.skill-card:hover { border-color: var(--accent); transform: translateY(-2px); background: var(--panel-2); }
.card-bundle {
font-size: 10.5px; letter-spacing: .03em; text-transform: uppercase;
color: var(--accent-2); font-weight: 600;
}
.card-title { margin: 0; font-size: 15px; line-height: 1.25; }
.card-summary { margin: 0; font-size: 12.5px; color: var(--muted); line-height: 1.5; }
.empty-msg { color: var(--muted); padding: 40px; text-align: center; grid-column: 1 / -1; }
/* ---------- Runner ---------- */
.runner { max-width: 880px; margin: 0 auto; }
.back {
background: var(--panel-2); color: var(--text); border: 1px solid var(--border);
padding: 7px 13px; border-radius: 8px; font-size: 13px; cursor: pointer; margin-bottom: 18px;
}
.back:hover { border-color: var(--accent); }
.skill-head { margin-bottom: 18px; }
.bundle-tag {
display: inline-block; font-size: 11px; color: var(--accent-2);
background: rgba(217,119,87,.12); padding: 3px 9px; border-radius: 99px; margin-bottom: 8px;
}
.skill-head h2 { margin: 0 0 6px; font-size: 23px; }
.skill-desc { color: var(--muted); font-size: 13.5px; line-height: 1.55; margin: 0; }
.input-form { display: flex; flex-direction: column; gap: 14px; margin: 22px 0; }
.field label { display: block; font-size: 13px; font-weight: 600; margin-bottom: 5px; }
.field .opt { color: var(--muted); font-weight: 400; }
.field .hint { color: var(--muted); font-weight: 400; font-size: 12px; }
.field input, .field textarea {
width: 100%; padding: 10px 12px; background: var(--panel); border: 1px solid var(--border);
border-radius: 8px; color: var(--text); font-size: 13.5px; font-family: inherit; resize: vertical;
}
.field textarea { min-height: 90px; }
.field input:focus, .field textarea:focus { outline: none; border-color: var(--accent); }
.actions { display: flex; align-items: center; gap: 12px; }
button.primary {
background: var(--accent); color: #1a1207; border: none; padding: 11px 18px;
border-radius: 8px; font-size: 14px; font-weight: 600; cursor: pointer;
}
button.primary:hover { background: var(--accent-2); }
button.primary:disabled { opacity: .5; cursor: not-allowed; }
button.ghost {
background: var(--panel-2); color: var(--text); border: 1px solid var(--border);
padding: 8px 13px; border-radius: 8px; font-size: 13px; cursor: pointer;
}
button.ghost:hover { border-color: var(--accent); }
.status { font-size: 13px; color: var(--muted); }
.status.err { color: #e07a6f; }
/* ---------- Output ---------- */
.output-wrap { margin-top: 24px; }
.output-toolbar {
display: flex; justify-content: space-between; align-items: center;
font-size: 12px; color: var(--muted); margin-bottom: 8px;
}
.output-toolbar > div { display: flex; gap: 8px; }
.output {
background: var(--panel); border: 1px solid var(--border); border-radius: var(--radius);
padding: 22px 26px; line-height: 1.6; font-size: 14.5px;
}
.markdown h1, .markdown h2, .markdown h3 { line-height: 1.3; }
.markdown h1 { font-size: 22px; }
.markdown h2 { font-size: 18px; border-bottom: 1px solid var(--border); padding-bottom: 5px; }
.markdown h3 { font-size: 15px; }
.markdown code { background: var(--panel-2); padding: 2px 6px; border-radius: 5px; font-size: 13px; }
.markdown pre { background: var(--panel-2); padding: 14px; border-radius: 8px; overflow-x: auto; }
.markdown pre code { background: none; padding: 0; }
.markdown table { border-collapse: collapse; width: 100%; margin: 12px 0; }
.markdown th, .markdown td { border: 1px solid var(--border); padding: 7px 10px; text-align: left; font-size: 13px; }
.markdown th { background: var(--panel-2); }
.markdown blockquote { border-left: 3px solid var(--accent); margin: 12px 0; padding: 2px 14px; color: var(--muted); }
.cursor::after { content: "▍"; color: var(--accent); animation: blink 1s steps(2) infinite; }
@keyframes blink { 50% { opacity: 0; } }
@media (max-width: 620px) {
.key-field input { width: 200px; }
.brand-text h1 { font-size: 15px; }
.gallery { grid-template-columns: 1fr; }
}