| name: | val-tool-playground |
|---|---|
| description: | Build developer tool playground vals on Val Town with a consistent UI pattern (Pico CSS, ARIA tabs, dual-mode HTTP endpoints). Use this skill whenever the user wants to create a new tool val, utility val, playground val, or any Val Town HTTP val that wraps a library/API with a web UI. Also trigger when the user says "create a val", "build a tool", "make a playground", or references existing tool vals like jwt-decoder, og-previewer, defuddle, dns-lookup, cron-tester, headers-inspector, etc. Even if the user doesn't mention Val Town explicitly, if they want a quick web tool with a UI and API, this skill applies. |
A skill for building developer tool vals on Val Town that follow a consistent, proven pattern. These are single-purpose utility tools with both a human-friendly UI and a programmatic API.
Every tool val serves two audiences from a single HTTP endpoint:
- Human UI — a clean web interface for interactive use
- Programmatic API — JSON or raw output for scripts, LLMs, and composition
The handler routes based on the request:
GET / → HTML page (the playground UI)
GET /path-or-params → raw/JSON output (the API)
POST / with JSON body → process input and return JSON
Prefer path-based params for proxy-style tools where the URL itself is the input (e.g. /https://example.com). Use query params for options and flags (?json=1, ?selector=.content). Support both patterns when it makes sense — path for the primary input, query for modifiers.
Choose based on complexity:
Simple tools (single concern, <200 lines of HTML):
main.ts # HTTP handler + inline HTML renderer
README.md
Complex tools (multiple features, rich UI):
main.ts # HTTP handler (routing only)
lib/
renderer.ts # HTML page generation
parser.ts # Core logic (parsing, fetching, transforming)
types.ts # TypeScript interfaces
README.md
The handler in main.ts should be thin — just routing. Push logic into lib/ files. This keeps the code easy to modify later.
All tool vals use the same UI foundation:
<!DOCTYPE html> <html lang="en"> <head> <meta charset="utf-8"> <meta name="viewport" content="width=device-width, initial-scale=1"> <meta name="color-scheme" content="light dark"> <title>Tool Name</title> <meta name="description" content="What this tool does"> <link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@picocss/pico@2/css/pico.min.css"> </head> <body> <main class="container"> <!-- header, tabs, content, footer --> </main> </body> </html>
Key rules:
- Pico CSS v2 via CDN, no other CSS framework
- System theme (no
data-themeattribute) — respects user's OS preference - Always include
<meta name="color-scheme" content="light dark"> - Container max-width: 900px
--pico-font-size: 16pxas base
Use <hgroup> for automatic muted subtitle:
<hgroup> <h1>📄 Tool Name</h1> <p>What it does. Link to <a href="https://github.com/..." target="_blank">underlying library</a> if wrapping one.</p> </hgroup>
When the tool has multiple modes (e.g. URL input vs paste HTML, decode vs encode), use this exact tab pattern:
<div role="tablist"> <button role="tab" aria-selected="true" aria-controls="first-tab" id="first-btn">First</button> <button role="tab" aria-selected="false" aria-controls="second-tab" id="second-btn">Second</button> </div> <div role="tabpanel" id="first-tab" class="active"> <!-- content --> </div> <div role="tabpanel" id="second-tab"> <!-- content --> </div>
Tab styling (consistent across all tool vals):
[role="tablist"] { border-bottom: 1px solid var(--pico-muted-border-color); margin-bottom: 1.5rem; }
[role="tab"] {
background: none; border: none;
border-bottom: 3px solid transparent; border-radius: 0;
padding: 0.75rem 1.5rem; margin: 0;
color: var(--pico-muted-color); font-weight: 500; cursor: pointer;
}
[role="tab"][aria-selected="true"] { color: var(--pico-primary); border-bottom-color: var(--pico-primary); }
[role="tab"]:hover { color: var(--pico-primary); }
[role="tabpanel"] { display: none; }
[role="tabpanel"].active { display: block; }
Tab switching JS:
const tabs = document.querySelectorAll('[role="tab"]');
const panels = document.querySelectorAll('[role="tabpanel"]');
tabs.forEach(tab => {
tab.addEventListener('click', () => {
tabs.forEach(t => t.setAttribute('aria-selected', 'false'));
panels.forEach(p => p.classList.remove('active'));
tab.setAttribute('aria-selected', 'true');
document.getElementById(tab.getAttribute('aria-controls')).classList.add('active');
});
});
URL input with action button — use Pico's group role:
<fieldset role="group"> <input type="url" id="urlInput" placeholder="https://example.com" aria-label="URL" autofocus> <button id="fetchBtn" aria-busy="false">Extract</button> </fieldset>
Textarea for paste input:
<textarea id="htmlInput" rows="6" placeholder="Paste content here..." style="font-family: var(--pico-font-family-monospace); font-size: 0.9rem;"></textarea>
Loading state — use Pico's aria-busy:
function setLoading(btn, loading) {
btn.setAttribute('aria-busy', loading);
btn.disabled = loading;
}
Enter key to submit:
$('#urlInput').addEventListener('keydown', (e) => {
if (e.key === 'Enter') $('#fetchBtn').click();
});
Code/markdown output with copy button:
<div class="output-wrap"> <button class="copy-btn" id="copyBtn" data-tooltip="Copy to clipboard" data-placement="left">⎘ Copy</button> <pre class="result"><code id="output"></code></pre> </div>
Copy button styling:
.output-wrap { position: relative; }
.copy-btn {
position: absolute; top: 0.5rem; right: 0.5rem; cursor: pointer;
background: var(--pico-secondary-background); border: 1px solid var(--pico-muted-border-color);
border-radius: 4px; padding: 0.3rem 0.6rem; font-size: 0.8rem; z-index: 1;
}
pre.result {
max-height: 70vh; overflow: auto; white-space: pre-wrap; word-break: break-word;
padding: 1rem; padding-top: 2.5rem; margin: 0; font-size: 0.85rem;
}
Copy JS with visual feedback:
$('#copyBtn').addEventListener('click', () => {
navigator.clipboard.writeText($('#output').textContent).then(() => {
$('#copyBtn').textContent = '\u2714 Copied';
setTimeout(() => { $('#copyBtn').innerHTML = '⎘ Copy'; }, 1500);
});
});
Metadata bar — for displaying parsed info (title, word count, timing, etc.):
<div class="meta" id="meta"></div>
.meta { display: flex; flex-wrap: wrap; gap: 0.5rem 1.5rem; font-size: 0.8rem; color: var(--pico-muted-color); }
<footer> <a href="https://www.val.town/u/kamalnrf" target="_blank">kamalnrf</a> · <a href="https://github.com/..." target="_blank">source lib</a> </footer>
footer { margin-top: 3rem; padding-top: 1rem; border-top: 1px solid var(--pico-muted-border-color); text-align: center; }
footer a { color: var(--pico-muted-color); font-size: 0.85rem; }
Use Pico's native <details> (no custom CSS needed):
<details> <summary>Advanced options</summary> <!-- selector inputs, format toggles, etc. --> </details>
Always add CORS headers on API responses (not needed on the HTML page response):
const cors = {
"Access-Control-Allow-Origin": "*",
"Access-Control-Allow-Methods": "GET, POST, OPTIONS",
"Access-Control-Allow-Headers": "Content-Type",
};
// Handle preflight
if (req.method === "OPTIONS") {
return new Response(null, { headers: cors });
}
Provide multiple output modes controlled by query params:
- Default: the most useful raw format (plain text, markdown, etc.)
?json=1or?format=json: full structured JSON with metadata- Path-based input for proxy tools:
/https://example.com
Return proper HTTP status codes with error messages in both modes:
// API error
if (wantJson) return Response.json({ error: e.message }, { status: 502, headers: cors });
// Raw error
return new Response(`Error: ${e.message}`, { status: 502, headers: cors });
In the UI, show errors inline using aria-invalid on inputs or var(--pico-del-color) for error text — never use alerts:
function showError(msg) {
document.getElementById('error').textContent = msg;
document.getElementById('output').style.display = 'none';
}
Pin npm package versions. Use npm: prefix for Deno:
import { Window } from "npm:happy-dom@17.4.4";
import { Defuddle } from "npm:defuddle@0.14.0/node";
If npm: fails due to sandbox restrictions, try https://esm.sh/package@version.
export default async function (req: Request): Promise<Response> {
// ...
}
Val Town vals cannot use fs, Deno.readFile, child_process, or Deno.Command. All state is in-memory or via Val Town's SQLite/blob storage.
Vals get a default URL like https://kamalnrf-valname.web.val.run. Custom subdomains (e.g. defuddle.val.run) can be configured in Val Town settings.
Keep READMEs brief. Focus on the API, skip UI documentation (the UI is self-explanatory):
# Tool Name
One-line description with link to [underlying library](https://...) if applicable.
**Live:** https://tool.val.run
## API
Primary usage example with the most composable URL form.
### Options
Table of query params.
### POST endpoint (if applicable)
Example request body.
## Limitations
Bullet list of known constraints.
- Val name: kebab-case, descriptive (
jwt-decoder,og-previewer,dns-lookup) - Description: terse, starts with verb or noun (
Decode & encode JWT tokens inline) - Emoji in H1 only, not in val name or description
Pico v2 provides more built-in components than we typically use. Before writing custom CSS, check if Pico already handles it.
Cards — <article> with optional <header> and <footer>.
Accordion — <details> + <summary>. Add name="group" for exclusive open. Add role="button" to <summary> for button style.
Loading — aria-busy="true" on any element (<article>, <button>, <span>).
Button variants — .secondary, .contrast, .outline, .outline secondary.
Button groups — role="group" on a wrapper. aria-current="true" for active state.
Tooltips — data-tooltip="text" on any element. data-placement="left|right|bottom" for positioning.
Form input groups — <fieldset role="group"> for horizontal input + button stacks.
Search — <form role="search"> for rounded search styling.
Color scheme — <meta name="color-scheme" content="light dark">. Per-element override with data-theme="light|dark".
Tables — native <table> styling. Wrap in <div class="overflow-auto"> for mobile.
Typography — <small>, <mark>, <ins>, <del>, <kbd>, blockquotes all styled natively.
<hgroup> — collapses margins, mutes last child. Use for title + subtitle headers.
Validation — aria-invalid="true|false" on inputs for colored borders. <small> helper text inherits color.
Switch toggles — <input type="checkbox" role="switch"> for native toggle switch.
Dropdowns — <details class="dropdown"> + <summary> + <ul>. Styled like <select>. Add role="button" to <summary> for button trigger.
Nav bar — <nav> with <ul> children auto-distributes horizontally. <aside><nav> stacks vertically.
Breadcrumbs — <nav aria-label="breadcrumb"> with <ul>.
Modals — <dialog> + <article> with <header> (close button) and <footer> (actions). .modal-is-open on <html> prevents scrolling.
Progress — <progress value="75" max="100"> for determinate. <progress> for indeterminate spinner.
Grid — .grid for auto-layout equal columns. Collapses on mobile (<768px).
- Tabs — Our ARIA
role="tablist"pattern is the right approach. - Copy button — positioned over code blocks.
- Metadata bar — flex row of key-value pairs.
- Writing custom
<details>CSS — Pico styles it natively as accordion. - Not using
<hgroup>— write separate h1 + p with custom margins instead. - Using
<div role="group">for forms — use<fieldset role="group">instead. - Missing
<meta name="color-scheme">— scrollbars and native controls won't respect dark mode. - Custom disabled styles — Pico handles
disablednatively. - Not using
data-tooltip— styled tooltips for free. - Custom error colors — use
var(--pico-del-color)oraria-invalid. - Missing
.overflow-autoon tables — breaks on mobile. - Custom toggle switches — use
role="switch"on checkbox. - Custom dropdown menus — use
<details class="dropdown">. - Not using
<progress>— indeterminate spinner for loading states.
- Don't use React/frameworks for simple tools — vanilla HTML/CSS/JS is faster and simpler
- Don't force a theme — let Pico auto-detect system preference
- Don't use
alert()orconfirm()— show feedback inline - Don't add external images or favicons — use emoji or inline SVG
- Don't over-abstract — a single
main.tsis fine for simple tools - Don't use Hono for single-purpose tools — raw
Request/Responseis sufficient - Don't forget the API mode — every tool val should be usable without the UI
- Don't use
Response.redirect— it's broken on Val Town. Usenew Response(null, { status: 302, headers: { Location: "/path" } })