• Blog
  • Docs
  • Pricing
  • We’re hiring!
Log inSign up
kamalnrf

kamalnrf

val-tool-playground-skill

Skill for building consistent tool vals with Pico CSS
Public
Like
val-tool-playground-skill
Home
Code
1
SKILL.md
Connections
Environment variables
Branches
1
Pull requests
Remixes
History
Val Town is a collaborative website to build and scale JavaScript apps.
Deploy APIs, crons, & store data – all from the browser, and deployed in milliseconds.
Sign up now
Code
/
SKILL.md
Code
/
SKILL.md
Search
…
Viewing readonly version of main branch: v1
View latest version
SKILL.md
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.

Val Tool Playground

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.

Architecture: Dual-Mode HTTP Handler

Every tool val serves two audiences from a single HTTP endpoint:

  1. Human UI — a clean web interface for interactive use
  2. 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.

File Structure

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.

UI Pattern: Pico CSS + ARIA Tabs

All tool vals use the same UI foundation:

Base Template

<!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-theme attribute) — respects user's OS preference
  • Always include <meta name="color-scheme" content="light dark">
  • Container max-width: 900px
  • --pico-font-size: 16px as base

Header

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>

Tabs (ARIA pattern)

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'); }); });

Input Patterns

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(); });

Output Patterns

Code/markdown output with copy button:

<div class="output-wrap"> <button class="copy-btn" id="copyBtn" data-tooltip="Copy to clipboard" data-placement="left">&#x2398; 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 = '&#x2398; 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

<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; }

Advanced Options

Use Pico's native <details> (no custom CSS needed):

<details> <summary>Advanced options</summary> <!-- selector inputs, format toggles, etc. --> </details>

API Design

CORS

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 }); }

Response Formats

Provide multiple output modes controlled by query params:

  • Default: the most useful raw format (plain text, markdown, etc.)
  • ?json=1 or ?format=json: full structured JSON with metadata
  • Path-based input for proxy tools: /https://example.com

Error Handling

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'; }

Val Town Conventions

Imports

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.

Handler Signature

export default async function (req: Request): Promise<Response> { // ... }

No Filesystem, No Subprocess

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.

Custom Domains

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.

README Pattern

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.

Naming Conventions

  • 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 CSS: Use Native Components, Don't Reinvent

Pico v2 provides more built-in components than we typically use. Before writing custom CSS, check if Pico already handles it.

Use natively (zero custom CSS needed)

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).

Requires custom CSS (not in Pico)

  • Tabs — Our ARIA role="tablist" pattern is the right approach.
  • Copy button — positioned over code blocks.
  • Metadata bar — flex row of key-value pairs.

Common mistakes

  1. Writing custom <details> CSS — Pico styles it natively as accordion.
  2. Not using <hgroup> — write separate h1 + p with custom margins instead.
  3. Using <div role="group"> for forms — use <fieldset role="group"> instead.
  4. Missing <meta name="color-scheme"> — scrollbars and native controls won't respect dark mode.
  5. Custom disabled styles — Pico handles disabled natively.
  6. Not using data-tooltip — styled tooltips for free.
  7. Custom error colors — use var(--pico-del-color) or aria-invalid.
  8. Missing .overflow-auto on tables — breaks on mobile.
  9. Custom toggle switches — use role="switch" on checkbox.
  10. Custom dropdown menus — use <details class="dropdown">.
  11. Not using <progress> — indeterminate spinner for loading states.

Anti-Patterns to Avoid

  • 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() or confirm() — show feedback inline
  • Don't add external images or favicons — use emoji or inline SVG
  • Don't over-abstract — a single main.ts is fine for simple tools
  • Don't use Hono for single-purpose tools — raw Request/Response is 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. Use new Response(null, { status: 302, headers: { Location: "/path" } })
FeaturesVersion controlCode intelligenceCLIMCP
Use cases
TeamsAI agentsSlackGTM
DocsShowcaseTemplatesNewestTrendingAPI examplesNPM packages
AboutAlternativesPricingBlogNewsletterCareers
We’re hiring!
Brandhi@val.townStatus
X (Twitter)
Discord community
GitHub discussions
YouTube channel
Bluesky
Open Source Pledge
Terms of usePrivacy policyAbuse contact
© 2026 Val Town, Inc.