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.
它做什麼
引導代理建立需要狀態、路由或可重用 UI 元件的多元件網頁產物。
運作方式
- 初始化 React、TypeScript、Vite、Tailwind CSS 和 shadcn/ui 專案。
- 使用產生的元件集開發網頁產物。
- 將 JavaScript、CSS 和資源打包為單一可分享的 HTML 檔案。
適合情境
互動式儀表板、多面板工具、資料密集型體驗及其他複雜瀏覽器產物。
包含兩個 shell 指令碼和一組隨附的 shadcn/ui 原始元件。初始化可能全域安裝 pnpm、下載 npm 套件、建立或修改專案目錄並解壓元件;打包會新增依賴、寫入 Parcel 設定、刪除先前的相對建構輸出並寫入 bundle.html。需要網路下載依賴,但不需要憑證;執行前請檢閱指令和目標路徑。