skills/webui/SKILL.md
Web UI development — Vite+ toolchain setup and browser-based E2E testing workflow.
npx skillsauth add hayeah/dotfiles webuiInstall this skill globally with one command. Works with Claude Code, Cursor, and Windsurf.
3 of 9 scanners reported clean
Some scanners were skipped, did not run, or reported a non-clean status. Review each row below.
When working on a page, use the tap API + screenshots to verify different states:
# Start dev server on an explicit port (see Port Usage below)
vp dev --port 5173
# Open a session
browser open http://localhost:5173 # → session key a3f2
# Manipulate state via tap API, screenshot each state
browser eval -s a3f2 '__tap__.library.searchQuery = "alice"'
browser screenshot -s a3f2 -o "$(tmpfile search.png)"
browser eval -s a3f2 '__tap__.openBook("alice-123", 5)'
browser screenshot -s a3f2 -o "$(tmpfile reader.png)"
# Or use multi-step screenshots for a quick sweep
browser screenshot --open http://localhost:5173 \
-o "$(tmpfile states.png)" \
--steps '
- wait: __tap__
- eval: __tap__.library.searchQuery = "alice"
wait: document.querySelector(".book-list")
- eval: __tap__.openBook("alice-123", 5)
wait: document.querySelector(".reader")
'
Read the __DOC__ in the root store to see what's available on __tap__, then drive the app through its states programmatically. Screenshot to confirm each state looks right.
Always start the dev server with an explicit port. Vite auto-finds an unused port when the default is taken, which causes the agent to lose track of the URL.
Find a free port (checks both 127.0.0.1 and 0.0.0.0):
python3 -c "import socket; s=socket.socket(); s.bind(('127.0.0.1',0)); p=s.getsockname()[1]; s.close(); s2=socket.socket(); s2.bind(('0.0.0.0',p)); s2.close(); print(p)"
Then start with that port:
vp dev --port 5173
BookLibrary.tsx)use prefix for hooks (useReadingProgress.ts)Group files by page (roughly mapping to routes), not by type (hooks/, utils/). Keep bespoke helpers and subcomponents together with the page they belong to.
pages/
reader/
Reader.tsx # entry component
Reader.test.tsx
ReaderToolbar.tsx # subcomponent
ReaderChapterNav.tsx # subcomponent
library/
Library.tsx
Library.test.tsx
LibrarySearch.tsx
LibraryShelf.tsx
State/
AppStore.ts
LibraryStore.ts
ReadingSession.ts
StoreContext.ts
Models/
BookEntry.ts
get* — e.g. user() not getUser().constructor(public foo: string, public bar: number) to declare and assign instance properties.init instance method.class Reader {
// Static factory for async setup
static async create(bookId: string) {
const metadata = await fetchMetadata(bookId);
return new Reader(bookId, metadata);
}
constructor(
public bookId: string,
public metadata: BookMetadata,
) {}
}
Start from the webui-template — Vite+, React, TypeScript, Tailwind v4, MobX, wouter, framer-motion, OKLCh design tokens with light/dark mode.
# Clone
git-quick-clone github.com/hayeah/webui-template
cd ~/github.com/hayeah/webui-template
pnpm install
vp dev --port 5173
# Or scaffold via Vite+ (pass project name after --)
vp create github:hayeah/webui-template -- myapp
cd myapp
vp dev --port 5173
The template includes /design and /design/dashboard sample pages — screenshot them after customizing the theme to verify coherence.
Use vp for all tooling: vp dev, vp check --fix, vp test, vp build. Everything in one vite.config.ts.
Use semantic design tokens everywhere (bg-primary, text-muted-foreground, text-success, bg-destructive/10) — never hardcode Tailwind colors like text-red-500. Customize the palette by editing OKLCh values in src/index.css :root and .dark.
Full reference: webui-template README | Vite+ Guide | Libraries
TLDR: Use the browser skill for E2E testing — screenshot, eval JS, inspect pages. Prefer --open one-shot mode for quick checks.
# Desktop (default)
browser screenshot --open http://localhost:5173
# Mobile device emulation
browser screenshot --open '{"device":"iPhone 15 Pro","url":"http://localhost:5173"}'
# Custom viewport with retina DPR
browser screenshot --open '{"url":"http://localhost:5173","viewport":"1280x800@2"}'
# Save to specific path
browser screenshot --open http://localhost:5173 -o "$(tmpfile desktop.png)"
Device names resolve against Puppeteer's KnownDevices (case-insensitive prefix match).
Run JS in the browser context — pass inline code or a file path:
browser eval --open http://localhost:5173 'document.title'
browser eval -s a3f2 'document.querySelectorAll("button").length'
browser eval -s a3f2 myscript.js
For scripts longer than ~10 lines, write to a temp file:
# Get a path
tmpfile scrape.js
# => $MDNOTES_ROOT/2026-03-30/tmp/143052_283-scrape.js
# Write your script to that path (use the Write tool)
# Run it
browser eval -s a3f2 $MDNOTES_ROOT/2026-03-30/tmp/143052_283-scrape.js
--open)Opens a window, runs the command, closes the window. No session management needed:
browser screenshot --open https://example.com
browser content --open https://example.com
browser eval --open https://example.com 'document.title'
browser network --open https://example.com --type xhr
Context spec formats:
https://example.com — desktop defaults'{"device":"iPhone 15 Pro","url":"https://example.com"}' — device emulationmobile.toml — reusable device profilesFor multi-step workflows, use persistent sessions:
# Open (run in background — process lifetime = session lifetime)
browser open http://localhost:5173 # prints session key, e.g. a3f2
# Use
browser screenshot -s a3f2
browser eval -s a3f2 'document.title'
browser nav -s a3f2 http://localhost:5173/other
# Close
browser close -s a3f2
Capture multiple states from a single page load:
browser screenshot --open http://localhost:5173 \
-o "$(tmpfile flow.png)" \
--steps '
- wait: document.querySelector(".loaded")
- eval: document.querySelector("button").click()
wait: document.querySelector(".modal")
'
Output files get an index: flow.1.png, flow.2.png, etc.
eval IIFEbrowser content for readable text extraction (uses Readability)browser network --type xhr to discover API endpointsFull reference: browser skill
TLDR: One MobX observable tree rooted in a single AppStore, with child stores organized by domain. All components observe paths in the tree via observer().
// State/AppStore.ts
class AppStore {
library = new LibraryStore();
sessions: ReadingSession[] = [];
constructor() { makeAutoObservable(this); }
openBook(bookID: string, chapter = 0) { /* ... */ }
}
// main.tsx
const appStore = new AppStore();
window.__tap__ = appStore;
Key rules:
AppStore → child stores → plain data. No scattered contexts.store.searchQuery = "alice"), action methods when touching multiple properties or returning results__DOC__ on the root store file covers the entire tree — one string, all paths and methodsuseState only for ephemeral view-local state (animation, hover). Everything else goes in the tree.setSearchQuery(q) for a single property. Just set it.Full reference: MobX Global State Guide
TLDR: Expose app state and actions on window.__tap__ so the agent can manipulate the app directly — more reliable than clicking buttons or filling inputs. Prefer stateless APIs: tabContent(index) not switchToTab(index) + tabContent(). Use __DOC__ as a source-level docstring. $ prefix for DOM refs, everything else is state/callbacks.
const __DOC__ = `
# MyPage — window.__tap__
- store.searchQuery (string) — current filter
- store.sheetOpen (boolean) — sheet visibility
- setActiveItem(id) — select item and close sheet
- $searchInput — the search <input>
`;
const store = observable({ searchQuery: '', sheetOpen: false });
useEffect(() => {
window.__tap__ = {
store,
setActiveItem(id: string) { /* ... */ },
get $searchInput() { return searchRef.current; },
};
return () => { window.__tap__ = null; };
}, []);
Key conventions:
window.__tap__ — always this name, one object per app__DOC__ — string constant, first thing after imports (like a Python module docstring)$ prefix — DOM elements ($searchInput, $scrollArea)Agent drives the page via browser eval:
browser eval -s a3f2 '__tap__.store.sheetOpen = true'
browser eval -s a3f2 '__tap__.$searchInput.focus()'
# Screenshot after state change
browser screenshot --open http://localhost:5173 \
--steps '
- eval: __tap__.store.sheetOpen = true
wait: document.querySelector(".sheet")
'
Full reference: Web Tap API Guide
/preview RouteTLDR: Dedicate a /preview route to render the same views against a MockDataSource so you can iterate on pure UI without the backend. Views depend only on a DataSource interface; MockDataSource and LiveDataSource both implement it. Expose the mock's mutators on window.__tap__ so the agent can drive any state via browser eval.
// App.tsx
<Switch>
<Route path="/preview" component={Preview} />
<Route><Live /></Route>
</Switch>
// data/tasks.ts — one interface, two implementations
export interface DashboardDataSource {
tasks: TaskItem[];
fetchWorklog(slug: string): Promise<string | null>;
}
// previews/Preview.tsx — mock + tap mutators, render the shared view tree
const ds = new MockDataSource();
window.__tap__ = {
get tasks() { return ds.tasks; },
addTask(t) { ds.addTask(t); refresh(); },
setTaskStatus(slug, status) { ds.setTaskStatus(slug, status); refresh(); },
};
browser open http://localhost:5173/preview # session a3f2
browser eval -s a3f2 '__tap__.setTaskStatus("foo", { type: "running" })'
browser screenshot -s a3f2 -o "$(tmpfile running.png)"
Key rules:
data/mock.ts inside views/ — views depend only on the DataSource interface and domain typesPreview.tsx and Live.tsx — the data source is the only differencerefresh() so plain-React components re-read the mock (MobX observers re-render for free)Full reference: /preview Route Guide
tools
Tooling and style guide for TypeScript projects.
development
Capture tmux pane content and export as text, HTML, SVG, PNG, or JPG. Use when you need a screenshot or text dump of a tmux pane for sharing, feeding to AI, or archiving terminal state.
testing
Copy-edit text. Fix grammar and/or tidy text into a concise listicle.
development
Design tokens and component patterns for Tailwind CSS projects.