I can't run anything in this session. I only have the file-read and skill tools, with no shell and no file-write tool. So nothing below has been executed, bundled or checked in a browser. This is the exact workflow plus the source to drop in.
1. Initialize the project
The skill's init-artifact.sh (I read it) needs Node 18+. It installs pnpm if it's missing. It then runs pnpm create vite with the react-ts template and pins Tailwind 3.4.1. After that it adds the @/ alias, installs the Radix and shadcn dependencies, and unpacks the 40+ shadcn components from shadcn-components.tar.gz.
cd /private/var/folders/ny/1wbtvz6s0hd918ll0msz_5680000gn/T/skill-trial-0cypru7o
bash .claude/skills/web-artifacts-builder/scripts/init-artifact.sh feedback-dashboard
cd feedback-dashboard
2. Develop
Replace src/App.tsx with the code below. Then run pnpm dev to preview. It uses only React and Tailwind, so it has no extra dependencies. The colors come from the dataviz reference palette: categorical slots in fixed order, a light and a dark set, and status colors that always appear with an icon and a label.
import { useEffect, useMemo, useState } from "react";
type Sev = "Critical" | "High" | "Medium" | "Low";
type Issue = {
id: string; title: string; cat: string; mentions: number; prev: number; sev: Sev;
summary: string; trend: number[]; regions: [string, number][]; quotes: string[];
};
const CATS = ["Performance", "Onboarding", "Billing", "Integrations", "Mobile"] as const;
const WEEKS = ["W36", "W37", "W38", "W39", "W40", "W41", "W42", "W43"];
const ISSUES: Issue[] = [
{ id: "FB-101", title: "Dashboard loads slowly on large workspaces", cat: "Performance", mentions: 412, prev: 301, sev: "Critical",
summary: "Workspaces with 10k+ records take 8–12s to render the home dashboard. Spike followed the W41 release.",
trend: [180, 190, 210, 230, 260, 301, 380, 412], regions: [["NA", 160], ["EMEA", 148], ["APAC", 82], ["LATAM", 22]],
quotes: ["“Monday mornings I just wait for the spinner.” — EMEA, Enterprise", "“Used to be instant before the last update.” — NA, Team"] },
{ id: "FB-102", title: "Setup wizard fails to import CSV with UTF-8 BOM", cat: "Onboarding", mentions: 187, prev: 164, sev: "High",
summary: "Import silently stops at step 3 for files exported from Excel. Mostly new accounts in their first week.",
trend: [120, 125, 140, 150, 152, 164, 175, 187], regions: [["APAC", 74], ["EMEA", 61], ["NA", 40], ["LATAM", 12]],
quotes: ["“No error, it just stops.” — APAC, Trial", "“Almost churned on day two.” — EMEA, Trial"] },
{ id: "FB-103", title: "Invoices show wrong currency after plan change", cat: "Billing", mentions: 143, prev: 158, sev: "High",
summary: "Mid-cycle plan changes render invoices in USD regardless of account currency. Fix shipped W42; tail remains.",
trend: [60, 72, 95, 120, 150, 158, 150, 143], regions: [["EMEA", 70], ["LATAM", 41], ["APAC", 24], ["NA", 8]],
quotes: ["“Finance flagged it twice this month.” — EMEA, Enterprise"] },
{ id: "FB-104", title: "Slack integration drops threaded replies", cat: "Integrations", mentions: 96, prev: 71, sev: "Medium",
summary: "Replies in threads are not synced back to the linked item. Reported by teams relying on Slack as the main inbox.",
trend: [40, 44, 50, 52, 58, 71, 85, 96], regions: [["NA", 44], ["EMEA", 33], ["APAC", 15], ["LATAM", 4]],
quotes: ["“We lose context every time someone replies in thread.” — NA, Team"] },
{ id: "FB-105", title: "iOS app logs users out after backgrounding", cat: "Mobile", mentions: 131, prev: 118, sev: "High",
summary: "Session token is not refreshed when the app resumes after 30+ minutes. Affects iOS 18 only.",
trend: [90, 95, 99, 104, 110, 118, 126, 131], regions: [["NA", 52], ["APAC", 39], ["EMEA", 33], ["LATAM", 7]],
quotes: ["“Have to log in every single time I open it.” — APAC, Team"] },
{ id: "FB-106", title: "Search ignores accented characters", cat: "Performance", mentions: 58, prev: 61, sev: "Low",
summary: "Searching “cafe” does not match “café”. Primarily non-English workspaces.",
trend: [50, 55, 60, 62, 60, 61, 59, 58], regions: [["EMEA", 31], ["LATAM", 19], ["APAC", 6], ["NA", 2]],
quotes: ["“Our whole catalog is in French.” — EMEA, Team"] },
{ id: "FB-107", title: "Unclear pricing page for seat add-ons", cat: "Billing", mentions: 74, prev: 66, sev: "Medium",
summary: "Prospects can’t tell whether add-on seats are billed monthly or annually.",
trend: [30, 35, 41, 52, 60, 66, 70, 74], regions: [["NA", 30], ["EMEA", 26], ["APAC", 14], ["LATAM", 4]],
quotes: ["“Is it per month or per year? Couldn’t tell.” — NA, Trial"] },
{ id: "FB-108", title: "Android push notifications arrive late", cat: "Mobile", mentions: 49, prev: 52, sev: "Medium",
summary: "Delays of 5–15 minutes on devices with aggressive battery optimisation.",
trend: [58, 60, 55, 54, 53, 52, 50, 49], regions: [["APAC", 22], ["LATAM", 14], ["EMEA", 9], ["NA", 4]],
quotes: ["“By the time I get it the meeting is over.” — LATAM, Team"] },
];
// Fixed categorical slots (light / dark) — color follows the category, never its rank.
const SLOT: Record<string, number> = Object.fromEntries(CATS.map((c, i) => [c, i + 1]));
const SEV_ICON: Record<Sev, string> = { Critical: "◆", High: "▲", Medium: "●", Low: "○" };
const SEV_VAR: Record<Sev, string> = { Critical: "--crit", High: "--serious", Medium: "--warn", Low: "--good" };
const CSS = `
.viz{--page:#f9f9f7;--surface:#fcfcfb;--ink:#0b0b0b;--ink2:#52514e;--muted:#898781;--grid:#e1e0d9;--rule:rgba(11,11,11,.1);--sel:#eeede8;
--s1:#2a78d6;--s2:#eb6834;--s3:#1baf7a;--s4:#eda100;--s5:#e87ba4;--good:#0ca30c;--warn:#fab219;--serious:#ec835a;--crit:#d03b3b;--up:#006300;color-scheme:light}
.dark .viz{--page:#0d0d0d;--surface:#1a1a19;--ink:#fff;--ink2:#c3c2b7;--grid:#2c2c2a;--rule:rgba(255,255,255,.1);--sel:#262624;
--s1:#3987e5;--s2:#d95926;--s3:#199e70;--s4:#c98500;--s5:#d55181;--up:#0ca30c}
.viz{font-family:system-ui,-apple-system,"Segoe UI",sans-serif;background:var(--page);color:var(--ink)}
`;
const delta = (i: Issue) => Math.round(((i.mentions - i.prev) / i.prev) * 100);
function Spark({ data, color }: { data: number[]; color: string }) {
const [hover, setHover] = useState<number | null>(null);
const W = 420, H = 110, P = 8, max = Math.max(...data) * 1.1;
const x = (i: number) => P + (i * (W - 2 * P)) / (data.length - 1);
const y = (v: number) => H - P - (v / max) * (H - 2 * P);
const path = data.map((v, i) => `${i ? "L" : "M"}${x(i)},${y(v)}`).join(" ");
const k = hover ?? data.length - 1;
return (
<div>
<div className="mb-1 flex items-baseline justify-between text-xs" style={{ color: "var(--ink2)" }}>
<span>{WEEKS[k]}</span><span className="text-sm font-semibold" style={{ color: "var(--ink)" }}>{data[k]} mentions</span>
</div>
<svg viewBox={`0 0 ${W} ${H}`} className="w-full" role="img" aria-label="Weekly mentions trend"
onMouseLeave={() => setHover(null)}
onMouseMove={(e) => {
const r = e.currentTarget.getBoundingClientRect();
const rel = ((e.clientX - r.left) / r.width) * W;
setHover(Math.max(0, Math.min(data.length - 1, Math.round(((rel - P) / (W - 2 * P)) * (data.length - 1)))));
}}>
{[0, 0.5, 1].map((t) => <line key={t} x1={P} x2={W - P} y1={H - P - t * (H - 2 * P) / 1.1} y2={H - P - t * (H - 2 * P) / 1.1} stroke="var(--grid)" />)}
<path d={path} fill="none" stroke={color} strokeWidth={2} strokeLinejoin="round" strokeLinecap="round" />
<line x1={x(k)} x2={x(k)} y1={P} y2={H - P} stroke="var(--muted)" strokeDasharray="2 3" />
<circle cx={x(k)} cy={y(data[k])} r={4.5} fill={color} stroke="var(--surface)" strokeWidth={2} />
</svg>
<div className="flex justify-between text-[11px]" style={{ color: "var(--muted)" }}><span>{WEEKS[0]}</span><span>{WEEKS[WEEKS.length - 1]}</span></div>
</div>
);
}
export default function App() {
const [dark, setDark] = useState(() => window.matchMedia?.("(prefers-color-scheme: dark)").matches ?? false);
const [active, setActive] = useState<string[]>([]);
const [sort, setSort] = useState<"mentions" | "growth">("mentions");
const [selId, setSelId] = useState(ISSUES[0].id);
useEffect(() => { document.documentElement.classList.toggle("dark", dark); }, [dark]);
const rows = useMemo(() => {
const r = ISSUES.filter((i) => !active.length || active.includes(i.cat));
return [...r].sort((a, b) => (sort === "mentions" ? b.mentions - a.mentions : delta(b) - delta(a)));
}, [active, sort]);
const sel = rows.find((i) => i.id === selId) ?? rows[0];
const total = ISSUES.reduce((s, i) => s + i.mentions, 0);
const prev = ISSUES.reduce((s, i) => s + i.prev, 0);
const crit = ISSUES.filter((i) => i.sev === "Critical" || i.sev === "High").reduce((s, i) => s + i.mentions, 0);
const fastest = [...ISSUES].sort((a, b) => delta(b) - delta(a))[0];
const col = (c: string) => `var(--s${SLOT[c]})`;
const toggle = (c: string) => setActive((a) => (a.includes(c) ? a.filter((x) => x !== c) : [...a, c]));
return (
<div className="viz min-h-screen">
<style>{CSS}</style>
<div className="mx-auto max-w-[1240px] px-6 py-8">
{/* Summary header */}
<header className="flex flex-wrap items-start justify-between gap-6 border-b pb-6" style={{ borderColor: "var(--rule)" }}>
<div>
<p className="text-xs uppercase tracking-widest" style={{ color: "var(--muted)" }}>Customer feedback · Week 43 · Oct 20–26 · All regions</p>
<h1 className="mt-1 text-3xl font-semibold tracking-tight">Weekly feedback themes</h1>
</div>
<button onClick={() => setDark(!dark)} aria-pressed={dark} aria-label="Toggle dark mode"
className="rounded-md border px-3 py-1.5 text-sm" style={{ borderColor: "var(--rule)", background: "var(--surface)", color: "var(--ink2)" }}>
{dark ? "☀ Light" : "☾ Dark"}
</button>
</header>
<section className="mt-6 grid grid-cols-2 gap-px overflow-hidden rounded-md border lg:grid-cols-4" style={{ borderColor: "var(--rule)", background: "var(--rule)" }}>
{[
["Total mentions", total.toLocaleString(), `${total > prev ? "▲" : "▼"} ${Math.abs(Math.round(((total - prev) / prev) * 100))}% vs last week`],
["High-severity share", `${Math.round((crit / total) * 100)}%`, `${crit.toLocaleString()} mentions Critical/High`],
["Fastest growing", `+${delta(fastest)}%`, fastest.title],
["Open themes", String(ISSUES.length), `${CATS.length} categories`],
].map(([l, v, s]) => (
<div key={l} className="p-4" style={{ background: "var(--surface)" }}>
<div className="text-xs" style={{ color: "var(--ink2)" }}>{l}</div>
<div className="mt-1 text-3xl font-semibold">{v}</div>
<div className="mt-1 truncate text-xs" style={{ color: "var(--muted)" }}>{s}</div>
</div>
))}
</section>
{/* Filters */}
<div className="mt-6 flex flex-wrap items-center gap-2">
<span className="mr-1 text-xs" style={{ color: "var(--muted)" }}>Category</span>
{CATS.map((c) => {
const on = active.includes(c);
const n = ISSUES.filter((i) => i.cat === c).reduce((s, i) => s + i.mentions, 0);
return (
<button key={c} onClick={() => toggle(c)} aria-pressed={on}
className="flex items-center gap-2 rounded-md border px-3 py-1.5 text-sm"
style={{ borderColor: on ? "var(--ink)" : "var(--rule)", background: on ? "var(--sel)" : "var(--surface)", color: "var(--ink)" }}>
<span className="inline-block h-2.5 w-2.5 rounded-sm" style={{ background: col(c) }} />
{c}<span style={{ color: "var(--muted)" }}>{n}</span>
</button>
);
})}
{active.length > 0 && <button onClick={() => setActive([])} className="text-sm underline" style={{ color: "var(--ink2)" }}>Clear</button>}
<div className="ml-auto flex items-center gap-2 text-xs" style={{ color: "var(--muted)" }}>
Sort
{(["mentions", "growth"] as const).map((s) => (
<button key={s} onClick={() => setSort(s)} className="rounded px-2 py-1"
style={{ background: sort === s ? "var(--sel)" : "transparent", color: sort === s ? "var(--ink)" : "var(--ink2)" }}>{s}</button>
))}
</div>
</div>
{/* List + detail */}
<div className="mt-4 grid gap-6 lg:grid-cols-[minmax(0,1.15fr)_minmax(0,1fr)]">
<ul className="overflow-hidden rounded-md border" style={{ borderColor: "var(--rule)", background: "var(--surface)" }}>
{rows.length === 0 && <li className="p-6 text-sm" style={{ color: "var(--ink2)" }}>No themes match this filter.</li>}
{rows.map((i) => {
const d = delta(i), on = sel?.id === i.id, max = rows[0] && Math.max(...rows.map((r) => r.mentions));
return (
<li key={i.id} style={{ borderTop: "1px solid var(--rule)" }} className="first:border-t-0">
<button onClick={() => setSelId(i.id)} aria-current={on} className="block w-full px-4 py-3 text-left"
style={{ background: on ? "var(--sel)" : "transparent", boxShadow: on ? `inset 3px 0 0 ${col(i.cat)}` : "none" }}>
<div className="flex items-start justify-between gap-4">
<div className="min-w-0">
<div className="text-sm font-medium">{i.title}</div>
<div className="mt-1 flex items-center gap-3 text-xs" style={{ color: "var(--ink2)" }}>
<span className="flex items-center gap-1.5"><span className="inline-block h-2 w-2 rounded-sm" style={{ background: col(i.cat) }} />{i.cat}</span>
<span style={{ color: `var(${SEV_VAR[i.sev]})` }}>{SEV_ICON[i.sev]}</span><span>{i.sev}</span>
</div>
</div>
<div className="shrink-0 text-right">
<div className="text-sm font-semibold">{i.mentions}</div>
<div className="text-xs" style={{ color: d > 0 ? "var(--ink2)" : "var(--up)" }}>{d > 0 ? "▲" : "▼"} {Math.abs(d)}%</div>
</div>
</div>
<div className="mt-2 h-1" style={{ background: "var(--grid)" }}>
<div className="h-full rounded-r-sm" style={{ width: `${(i.mentions / max) * 100}%`, background: col(i.cat) }} />
</div>
</button>
</li>
);
})}
</ul>
{/* Detail panel */}
{sel && (
<aside className="self-start rounded-md border p-5 lg:sticky lg:top-6" style={{ borderColor: "var(--rule)", background: "var(--surface)" }}>
<div className="flex items-center gap-2 text-xs" style={{ color: "var(--ink2)" }}>
<span className="inline-block h-2.5 w-2.5 rounded-sm" style={{ background: col(sel.cat) }} />{sel.cat}<span>·</span><span>{sel.id}</span>
<span className="ml-auto" style={{ color: `var(${SEV_VAR[sel.sev]})` }}>{SEV_ICON[sel.sev]}</span><span>{sel.sev}</span>
</div>
<h2 className="mt-2 text-xl font-semibold leading-snug">{sel.title}</h2>
<p className="mt-2 text-sm leading-relaxed" style={{ color: "var(--ink2)" }}>{sel.summary}</p>
<h3 className="mb-2 mt-5 text-xs uppercase tracking-widest" style={{ color: "var(--muted)" }}>8-week trend</h3>
<Spark data={sel.trend} color={col(sel.cat)} />
<h3 className="mb-2 mt-5 text-xs uppercase tracking-widest" style={{ color: "var(--muted)" }}>By region</h3>
<div className="space-y-2">
{sel.regions.map(([r, v]) => (
<div key={r} className="grid grid-cols-[52px_1fr_36px] items-center gap-2 text-xs" title={`${r}: ${v} mentions`}>
<span style={{ color: "var(--ink2)" }}>{r}</span>
<div className="h-2" style={{ background: "var(--grid)" }}>
<div className="h-full rounded-r-sm" style={{ width: `${(v / sel.regions[0][1]) * 100}%`, background: col(sel.cat) }} />
</div>
<span className="text-right">{v}</span>
</div>
))}
</div>
<h3 className="mb-2 mt-5 text-xs uppercase tracking-widest" style={{ color: "var(--muted)" }}>Representative quotes</h3>
<ul className="space-y-2">
{sel.quotes.map((q) => <li key={q} className="border-l-2 pl-3 text-sm italic" style={{ borderColor: "var(--rule)", color: "var(--ink2)" }}>{q}</li>)}
</ul>
</aside>
)}
</div>
</div>
</div>
);
}
The sample data is placeholder. Swap ISSUES for your real feed. Colors follow each category and not its rank, so filtering never repaints the remaining categories.
3. Bundle to a single HTML file
bash ../.claude/skills/web-artifacts-builder/scripts/bundle-artifact.sh
Run it from inside feedback-dashboard, where index.html sits at the root. Per the skill, the script does four things:
- It installs Parcel,
@parcel/config-default,parcel-resolver-tspathsandhtml-inline. - It writes a
.parcelrcthat supports the@/alias. - It builds with Parcel and no source maps.
- It inlines all the JS and CSS into
bundle.html, which you can share as the artifact.
What I'd still check
- Palette validation: I haven't run
validate_palette.js. The five categorical slots are the first five of the reference palette, which is documented as validated for adjacent pairs. The yellow and magenta slots are below 3:1 contrast on the light surface. They're always paired with a visible text label, as the relief rule requires. - Build and visual check: I haven't built the code or looked at it in a browser. I'd do both after bundling.
If you give me shell and write access, I'll run these steps, fix any build errors and screenshot both themes.
Real run recorded with claude-code / claude-sonnet-5-5. Output is shown verbatim, unmodified.
Zweck
Leitet die Erstellung mehrteiliger Webartefakte für komplexe Oberflächen mit Status, Routing oder wiederverwendbaren UI-Komponenten an.
Arbeitsweise
- Initialisiert ein Projekt mit React, TypeScript, Vite, Tailwind CSS und shadcn/ui.
- Entwickelt das Artefakt mit dem bereitgestellten Komponentensatz.
- Bündelt JavaScript, CSS und Assets in einer einzigen teilbaren HTML-Datei.
Geeignet für
Interaktive Dashboards, Werkzeuge mit mehreren Bereichen, datenintensive Erlebnisse und andere komplexe Browserartefakte.
Enthält zwei Shell-Skripte und ein gebündeltes Set von shadcn/ui-Quellkomponenten. Die Initialisierung kann pnpm global installieren, npm-Pakete herunterladen, ein Projektverzeichnis erstellen oder ändern und Komponenten entpacken. Das Bündeln fügt Abhängigkeiten und eine Parcel-Konfiguration hinzu, entfernt frühere relative Build-Ausgaben und schreibt bundle.html. Für Pakete wird Netzwerkzugriff benötigt, aber keine Zugangsdaten. Prüfen Sie Befehle und Zielpfade vor der Ausführung.