next-ai-draw-io
Draw and edit draw.io diagrams by chatting with an AI. Describe a diagram in a sentence and the AI draws it on a real draw.io canvas. Edit it by hand like any draw.io file, or select a few shapes and tell the AI what to change.
What ux-ui-agent-skills runs automatically, and when. A hook is a command Claude Code fires at a fixed moment, without you asking for it.
Claude Code
> /plugin marketplace add plugin87/ux-ui-agent-skills> /plugin install ux-ui-agent-skills@ux-ui-agent-skills
Cursor
Cursor also runs Claude Code hooks from .claude/settings.json, so copy these hooks there to use them in Cursor.
What fires, and when
SessionStart
Fires once when a session begins, and again after a context compaction. It is where a plugin sets up its environment, or restores state the compaction dropped.
startup|resume|clear|compactnode "${CLAUDE_PLUGIN_ROOT}/hooks/session-start-preflight.mjs"PostToolUse
Write|Editnode "${CLAUDE_PLUGIN_ROOT}/hooks/posttooluse-fast-gates.mjs"Stop
node "${CLAUDE_PLUGIN_ROOT}/hooks/stop-require-measurement.mjs"Where it lives
/**
* Is there a browser the render gates can drive? Answered without launching
* one, because both hooks must finish fast and must not spawn anything.
*
* One definition for both hooks. The first version was a macOS path list
* copied into each: on Linux a system Chrome - which the gates launch fine
* through `channel: 'chrome'` - was never seen, so the preflight said the gates
* could not run while they ran, and the Stop hook downgraded to a warning (#26).
*/
import { existsSync } from 'node:fs';
import { join, delimiter } from 'node:path';
const home = process.env.HOME || process.env.USERPROFILE || '';
/** Where `channel: 'chrome'` and Playwright's own downloads end up, per platform. */
const FIXED = [
// macOS
'/Applications/Google Chrome.app/Contents/MacOS/Google Chrome',
'/Applications/Chromium.app/Contents/MacOS/Chromium',
join(home, 'Library/Caches/ms-playwright'),
// Linux
'/opt/google/chrome/chrome',
'/usr/bin/google-chrome',
'/usr/bin/google-chrome-stable',
'/usr/bin/chromium',
'/usr/bin/chromium-browser',
'/snap/bin/chromium',
join(home, '.cache/ms-playwright'),
// Windows
join(process.env.LOCALAPPDATA || '', 'ms-playwright'),
join(process.env.PROGRAMFILES || 'C:\\Program Files', 'Google/Chrome/Application/chrome.exe'),
join(process.env.LOCALAPPDATA || '', 'Google/Chrome/Application/chrome.exe'),
];
const ON_PATH = ['google-chrome', 'google-chrome-stable', 'chromium', 'chromium-browser', 'chrome'];
export function browserPresent() {
const custom = process.env.PLAYWRIGHT_BROWSERS_PATH;
if (custom && custom !== '0' && existsSync(custom)) return true;
if (FIXED.some(p => p && existsSync(p))) return true;
for (const dir of (process.env.PATH || '').split(delimiter)) {
if (dir && ON_PATH.some(n => existsSync(join(dir, n)))) return true;
}
return false;
}
/**
* The observability log: what the hooks saw, one JSON line per event.
*
* The edit ledger answers one question for the Stop hook and is thrown away
* with the session. This keeps the record afterwards, so a person can ask
* whether an agent followed the rules - edited a page, was it measured, was it
* blocked, did it fix what the fast gates caught or quit with it open.
*
* Three rules it never breaks:
* - Local only. Nothing here is sent anywhere.
* - Writing an event can never fail a hook or slow it noticeably. Every
* write is wrapped; a log we cannot write is a log we do not have.
* - No session id, no event: the same rule the ledger follows, for the same
* reason (a shared fallback name is how sessions came to mix).
*
* DS_OBSERVE=0 turns it off. Files older than RETAIN_DAYS are deleted at
* session start.
*/
import { createHash } from 'node:crypto';
import { mkdirSync, appendFileSync, readdirSync, statSync, rmSync, readFileSync } from 'node:fs';
import { join, dirname } from 'node:path';
import { fileURLToPath } from 'node:url';
import { projectDir } from './_ledger.mjs';
export const RETAIN_DAYS = 30;
const ROOT = join(dirname(fileURLToPath(import.meta.url)), '..');
export function eventsDir() {
const data = process.env.CLAUDE_PLUGIN_DATA;
if (data) return join(data, 'observe');
return join(projectDir(), '.ds-receipts', 'observe');
}
export function observing() {
return process.env.DS_OBSERVE !== '0';
}
export function kitVersion() {
try { return JSON.parse(readFileSync(join(ROOT, 'package.json'), 'utf8')).version; }
catch { return null; }
}
/** One file per project per day: few files, and a day is easy to prune. */
function eventsFile(at) {
const tag = createHash('sha256').update(projectDir()).digest('hex').slice(0, 10);
return join(eventsDir(), `events-${tag}-${at.slice(0, 10)}.jsonl`);
}
/** Append one event. Returns true when it was written; never throws. */
export function recordEvent(payload, kind, data = {}) {
try {
if (!observing()) return false;
const session = String(payload?.session_id || '').replace(/[^\w-]/g, '');
if (!session) return false;
const at = new Date().toISOString();
const event = { v: 1, at, kind, session, project: projectDir(), ...data };
mkdirSync(eventsDir(), { recursive: true });
appendFileSync(eventsFile(at), JSON.stringify(event) + '\n');
return true;
} catch {
return false;
}
}
/** Delete event files not written to in RETAIN_DAYS. Never throws. */
export function pruneEvents(now = Date.now()) {
let removed = 0;
try {
const dir = eventsDir();
for (const f of readdirSync(dir)) {
if (!/^events-.*\.jsonl$/.test(f)) continue;
const p = join(dir, f);
if (now - statSync(p).mtimeMs > RETAIN_DAYS * 86400000) { rmSync(p, { force: true }); removed++; }
}
} catch { /* no directory yet */ }
return removed;
}
/**
* Where the hooks record what this session edited.
*
* Its own file because both hooks need the same path and neither may import
* the other: `posttooluse-fast-gates.mjs` runs its work at the top level, so
* importing it from the Stop hook would run a PostToolUse pass as a side
* effect of asking where a file lives.
*
* One ledger per project AND per session. Under a plugin install the data
* directory is shared by every project on the machine, and CLAUDE_SESSION_ID
* is not always set - a `claude -p` run has none - so keying on the session
* alone put every such run in one `edited-local.jsonl`. Observed on
* 2026-10-08: a session in /tmp/mcptest was told that ten files under
* /tmp/blind-plugin were unmeasured. Being blocked by work you did not do, in
* a repository you are not in, is the fastest way to get a hook switched off.
*/
import { createHash } from 'node:crypto';
import { readdirSync, rmSync } from 'node:fs';
import { join, resolve, relative, isAbsolute } from 'node:path';
export function ledgerDir() {
const data = process.env.CLAUDE_PLUGIN_DATA;
if (data) return join(data, 'receipts');
return join(process.env.CLAUDE_PROJECT_DIR || process.cwd(), '.ds-receipts');
}
export function projectDir() {
return resolve(process.env.CLAUDE_PROJECT_DIR || process.cwd());
}
/**
* The ledger for this project and this session, or null when the session is
* unknown. The hook's stdin carries session_id on every event; the environment
* variable does not reach hook processes. With neither there is no ledger at
* all: a shared fallback name is exactly how sessions came to block each other
* (2026-10-10, five files from two other repositories).
*/
export function ledgerFile(payload = {}) {
const session = String(payload.session_id || process.env.CLAUDE_SESSION_ID || '')
.replace(/[^\w-]/g, '');
if (!session) return null;
const tag = createHash('sha256').update(projectDir()).digest('hex').slice(0, 10);
return join(ledgerDir(), `edited-${tag}-${session}.jsonl`);
}
/** True when `p` lies inside the current project. The Stop hook never names anything else. */
export function insideProject(p) {
const rel = relative(projectDir(), resolve(p));
return rel !== '' && !rel.startsWith('..') && !isAbsolute(rel);
}
/**
* Delete ledgers written by versions up to 2.9.6, whose names were shared
* across sessions (`edited-local.jsonl`, `edited-<tag>-local.jsonl`). Left in
* place they keep a stale list of edits that an older hook still reads.
*/
export function removeLegacyLedgers() {
let removed = 0;
try {
for (const f of readdirSync(ledgerDir())) {
if (/^edited-(?:[0-9a-f]{10}-)?local\.jsonl$/.test(f)) {
rmSync(join(ledgerDir(), f), { force: true });
removed++;
}
}
} catch { /* no ledger directory yet: nothing to clean */ }
return removed;
}
#!/usr/bin/env node
/**
* PostToolUse on Write and Edit: run the browser-free gates on the file that
* just changed, and remember that it changed.
*
* These three are cheap enough to run on every edit and catch the drift that is
* hardest to see later: a raw hex that bypassed the theme, an emoji used as an
* icon, a var() that resolves to nothing. A finding exits 2, which is how a
* PostToolUse hook gets its stderr in front of Claude after the write.
*
* It also appends the file to a per-session ledger, which the Stop hook reads to
* ask whether anything was edited and never measured.
*/
import { spawnSync } from 'node:child_process';
import { mkdirSync, appendFileSync, existsSync, readFileSync } from 'node:fs';
import { join, dirname, extname, resolve, basename } from 'node:path';
import { fileURLToPath } from 'node:url';
import { ledgerDir, ledgerFile } from './_ledger.mjs';
import { recordEvent } from './_events.mjs';
const ROOT = join(dirname(fileURLToPath(import.meta.url)), '..');
const UI_EXT = new Set(['.html', '.htm', '.css', '.scss', '.tsx', '.jsx', '.vue', '.svelte']);
// `require` is not available in an ES module. The first version of this hook
// used it, the catch swallowed the ReferenceError, and the hook silently did
// nothing while exiting 0 - a hook that never fires is indistinguishable from a
// codebase with no findings.
let input = '';
try {
input = readFileSync(0, 'utf8');
} catch { /* no stdin (run by hand): nothing to check */ }
let payload = {};
try { payload = JSON.parse(input || '{}'); } catch { payload = {}; }
const file = payload?.tool_input?.file_path || payload?.tool_input?.path;
if (!file || !UI_EXT.has(extname(file)) || !existsSync(file)) process.exit(0);
// Record the edit first. If a gate below crashes, the Stop hook must still know
// this file was touched - a missing ledger entry would read as "nothing to check".
const ledger = ledgerFile(payload);
if (ledger) try {
mkdirSync(ledgerDir(), { recursive: true });
appendFileSync(ledger, JSON.stringify({ at: new Date().toISOString(), path: resolve(file) }) + '\n');
} catch { /* a ledger we cannot write is reported by the Stop hook, not here */ }
const GATES = [
['python3', [join(ROOT, 'scripts/lint_hardcodes.py'), file]],
['python3', [join(ROOT, 'scripts/check_no_emoji.py'), file]],
// A house rule, caught at the moment it is written rather than at review.
['python3', [join(ROOT, 'scripts/lint_native_select.py'), file]],
];
/* validate_theme_refs.py is NOT here, and the reason is worth keeping.
*
* It is the other half of "one theme, one source of truth" - lint_hardcodes
* catches a value written instead of a token, and theme_refs catches a token
* that resolves to nothing. It runs in 25ms with no browser, so it looks like
* an obvious addition.
*
* It needs to be told which theme to check against, and a write hook does not
* know. Guessing - treating the edited file as its own theme - is correct only
* for a self-contained file, and produces a false failure on every write in the
* setup most real projects have: a shared theme imported once at the app root,
* where no individual component file defines anything.
*
* A gate that fires wrongly on every save is worse than the same gate running
* one step later with the right argument, which is what CI already does. Added
* on 2026-10-08, reverted the same hour, when the hook's own test caught it
* failing a clean file. */
const findings = [];
const results = [];
for (const [cmd, args] of GATES) {
const r = spawnSync(cmd, args, { encoding: 'utf8', timeout: 20000 });
const lines = r.status === 1
? (r.stdout || '').trim().split('\n').filter(l => /^\s*x |FAIL/.test(l)).slice(0, 6)
: [];
if (r.status === 1) findings.push(lines.join('\n'));
results.push({ gate: basename(args[0]), exit: r.status, findings: lines });
}
recordEvent(payload, 'edit', { path: resolve(file), tool: payload.tool_name || null, gates: results });
if (findings.length) {
console.error(`[ux-ui-agent-skills] ${file} has findings a gate caught on write:\n`
+ findings.join('\n')
+ `\n\nFix them now, while the change is small. These are the browser-free gates;`
+ ` the render gates still have to be run through scripts/gate.mjs.`);
process.exit(2);
}
process.exit(0);
#!/usr/bin/env node
/**
* SessionStart: say what can actually be measured here, before anything is built.
*
* Most of this kit's gates open a real browser. Without one they print SKIPPED
* and exit 0, which reads like a pass to a model scanning output. The agent
* should know that from the first turn, not discover it when it is about to
* state a contrast ratio.
*
* Prints to stdout, which Claude Code adds to the session context. Installs
* nothing, and must finish fast.
*/
import { existsSync, readFileSync } from 'node:fs';
import { join, dirname } from 'node:path';
import { fileURLToPath } from 'node:url';
import { removeLegacyLedgers } from './_ledger.mjs';
import { browserPresent } from './_browser.mjs';
import { recordEvent, pruneEvents, kitVersion } from './_events.mjs';
const ROOT = join(dirname(fileURLToPath(import.meta.url)), '..');
let payload = {};
try { payload = JSON.parse(readFileSync(0, 'utf8') || '{}'); } catch { payload = {}; }
function playwrightPresent() {
for (const p of [join(ROOT, 'node_modules/playwright/package.json'),
join(process.cwd(), 'node_modules/playwright/package.json')]) {
if (existsSync(p)) return true;
}
return false;
}
const pw = playwrightPresent();
const br = browserPresent();
const lines = ['[ux-ui-agent-skills] Verification preflight'];
if (pw && br) {
lines.push(' Render gates CAN run here. Run them through scripts/gate.mjs so the');
lines.push(' result is recorded, and quote the output rather than remembering it.');
} else {
lines.push(` Render gates CANNOT run here (playwright: ${pw ? 'yes' : 'no'}, browser: ${br ? 'yes' : 'no'}).`);
lines.push(' Fix: npm install && npx playwright install chrome');
lines.push(' Until then, every contrast, state, axe, target-size, overflow and');
lines.push(' responsive claim is UNMEASURED. Say so in your answer. A render gate');
lines.push(' with no browser prints SKIPPED and exits 0 - that is not a pass.');
}
lines.push(' Browser-free gates always work: lint_hardcodes, check_no_emoji,');
lines.push(' validate_theme_refs, validate_tokens, validate_contrast.');
// Pre-2.9.7 ledgers were shared across sessions and projects; clear them.
removeLegacyLedgers();
pruneEvents();
recordEvent(payload, 'session_start', {
source: payload.source || null, version: kitVersion(), playwright: pw, browser: br,
});
console.log(lines.join('\n'));
process.exit(0);
#!/usr/bin/env node
/**
* Stop: do not finish a session that edited UI and measured none of it.
*
* This is the rule "never state a number you did not measure" turned into
* something that happens rather than something the model is asked to remember.
* If the session edited a renderable page and there is no live, passing receipt
* for it - no receipt at all, one whose hash no longer matches because the page
* was edited after it was measured, or only receipts from a gate that failed or
* skipped - say so and block.
*
* Deliberate softenings:
* - stop_hook_active always lets the turn end. It is true when Claude Code is
* already continuing because of a Stop hook, and blocking again there never
* ends. The state is still computed first so the observability log records
* whether the agent finished with pages unmeasured.
* - Only pages (.html/.htm) are required. A stylesheet or a component source
* cannot be rendered by itself: verify_states on a .css file checks zero
* elements and passes, so demanding a receipt for one adds friction and
* measures nothing.
* - With no browser on the machine the render gates cannot run at all, so
* blocking would be a demand nobody can satisfy. It downgrades to requiring
* the answer to say the work is unmeasured.
*
* And one rule it must never break: the command it prints has to be one that
* clears it. Version 2.9.8 printed a command whose receipts landed where the
* hook never looked, and blocked every turn for the rest of the session (#24).
*/
import { readFileSync, existsSync } from 'node:fs';
import { join, dirname, relative, extname } from 'node:path';
import { fileURLToPath } from 'node:url';
import { liveReceiptsFor } from '../scripts/gate.mjs';
import { ledgerFile, insideProject, projectDir } from './_ledger.mjs';
import { browserPresent } from './_browser.mjs';
import { recordEvent } from './_events.mjs';
const ROOT = join(dirname(fileURLToPath(import.meta.url)), '..');
const PAGES = new Set(['.html', '.htm']);
let payload = {};
try { payload = JSON.parse(readFileSync(0, 'utf8') || '{}'); } catch { payload = {}; }
/* The same path the writer builds, imported rather than rebuilt: two copies of
a path formula drift, and when they do this hook silently stops seeing any
edits at all - which looks exactly like a clean session. */
const ledger = ledgerFile(payload);
if (!ledger || !existsSync(ledger)) process.exit(0);
// What the observability log keeps about the end of a turn. Pages only, with
// their state, so a report can say "edited 3, measured 2" without re-deriving it.
const stopEvent = (outcome, rows = []) => recordEvent(payload, 'stop', {
outcome, reentry: Boolean(payload.stop_hook_active),
pages: rows.map(({ p, live, passing }) => ({
path: p,
state: passing.length ? 'passing' : live.length ? 'failing' : 'not_measured',
...(live.length && !passing.length
? { gate: live[live.length - 1].gate, exit: live[live.length - 1].exit, skipped: Boolean(live[live.length - 1].skipped) }
: {}),
})),
});
const edited = [...new Set(
readFileSync(ledger, 'utf8').split('\n').filter(Boolean)
.map(l => { try { return JSON.parse(l).path; } catch { return null; } })
.filter(Boolean)
// Only this project's pages. A ledger that somehow holds another
// repository's edits must never block you here.
.filter(p => PAGES.has(extname(p).toLowerCase()) && insideProject(p) && existsSync(p)))];
if (!edited.length) { stopEvent('no_pages'); process.exit(0); }
const status = edited.map(p => {
const live = liveReceiptsFor(p);
const passing = live.filter(r => r.exit === 0 && !r.skipped);
return { p, live, passing };
});
const open = status.filter(s => !s.passing.length);
// Already continuing because of a Stop hook: let it through or it never ends.
// Recorded first, because this is the one case where an agent can finish with
// pages still unmeasured, and the log is where that becomes visible.
if (payload.stop_hook_active) { stopEvent(open.length ? 'released' : 'pass', status); process.exit(0); }
if (!open.length) { stopEvent('pass', status); process.exit(0); }
const shown = p => relative(projectDir(), p) || p;
const list = open.map(({ p, live }) => {
if (!live.length) return ` ${shown(p)} (not measured)`;
const last = live[live.length - 1];
return ` ${shown(p)} (measured, failing: ${last.gate} exit ${last.exit}${last.skipped ? ', SKIPPED' : ''})`;
}).join('\n');
if (!browserPresent()) {
console.error(
`[ux-ui-agent-skills] ${open.length} page(s) were edited and cannot be measured `
+ `here - this machine has no browser for the render gates:\n${list}\n\n`
+ `Not a blocker, but your answer must say the result is UNMEASURED rather than `
+ `implying it passed. Installing one: npx playwright install chrome`);
stopEvent('warn_no_browser', status);
process.exit(0);
}
// Absolute paths and the data dir spelled out, so the command works from any
// cwd and writes where this hook reads.
const gate = join(ROOT, 'scripts/gate.mjs');
const data = process.env.CLAUDE_PLUGIN_DATA ? `CLAUDE_PLUGIN_DATA="${process.env.CLAUDE_PLUGIN_DATA}" ` : '';
const first = open[0].p;
console.error(
`[ux-ui-agent-skills] ${open.length} page(s) edited in this session have no live, `
+ `passing gate receipt:\n${list}\n\n`
+ `Measure each one through the wrapper, which records what it measured:\n`
+ ` ${data}node "${gate}" verify_states.mjs "${first}"\n\n`
+ `A receipt counts only while the page is unchanged since it was measured, and only `
+ `if the gate passed. A failing gate means fix the page, then measure again.`);
stopEvent('block', status);
process.exit(2);
Read the script before you install anything that runs on your machine. This is the one part of a plugin that acts without being asked.
Draw and edit draw.io diagrams by chatting with an AI. Describe a diagram in a sentence and the AI draws it on a real draw.io canvas. Edit it by hand like any draw.io file, or select a few shapes and tell the AI what to change.
Assembles a virtual design team inside Claude Code to produce UI, UX, visual, social media, email, and data design output. A design manager picks the right specialist roles for each task.
An agent design team you control. 10 agents that run an inclusive design process while you direct. The system notices how you design and reflects it back. Designpowers is an open, model-agnostic design workflow that gives you a team of 10 design agents.
Bridge connects Claude Code to the Figma Plugin API over WebSocket, so you can design, extract your design system, and fix or ship Figma components from the terminal.