Designs and verifies user interfaces: a stack-neutral design source skill, measured audits of visuals, interaction and accessibility (WCAG 2.2 AA, European…

Design a user interface from one written source, then check the running UI against it with measurements, for the person who builds an interface and answers for its accessibility.
A UI made by taste and judged by looking ships defects nobody measured, and some of them lock people out entirely. qsm-uix exists so that an interface starts from one written design source whose first constraint is that everyone can use it, and is then verified against that source by measurement, for the person who builds and publishes interfaces and answers for their accessibility. It takes the stance that a finding without a measurement is an opinion. It refuses to be a catalogue of palettes and fonts, a generator that picks a look for a product, or a slide tool. When designs carry their source and their numbers by default, this plugin can go.
| Kind | Name | What it does |
|---|---|---|
| Skill | ui-design | Writes a stack-neutral design source: tokens, on-pairs, light and dark themes, a style with its accessibility rules |
| Skill | ui-audit | Measures static visuals: spacing, geometry, colour, contrast, type, icons, elevation, theme parity |
| Skill | ux-audit | Traces behaviour: discoverability, feedback, targets, motion, reversibility, error recovery, forms |
| Skill | a11y-audit | Drives the UI by keyboard and the accessibility tree, and fills a per-criterion conformance table |
| Skill | a11y-conformance | Gives the WCAG 2.2 A and AA criteria and the law behind them (European Accessibility Act, EN 301 549) |
| Skill | qml-ui | Quickshell and QML on Hyprland: Theme singleton, item-tree dump, qmllint and qmlformat |
| Skill | css-ui | Plain CSS and HTML, with a short Tailwind section |
| Skill | react-ui | What React leaves to you: focus and aria, forms, loading states |
| Skill | tui-ui | Terminal output: colour tiers, NO_COLOR, width, non-TTY output, progress |
| Agent | visualizer | Renders findings or other structured data as one self-contained HTML page |
| CLI | qsm-uix | 9 verbs that turn a screen into numbers, see USAGE.md |
claude plugin install qsm-uix@qsm-marketplace
bun plugins/qsm-uix/scripts/install.ts
The install script compiles qsm-uix, links it into ~/.local/bin and checks the external tools. It names them and never installs them. More in INSTALL.md.
Ask: "audit this UI", "is this accessible by keyboard only?" or "design the colours and spacing for this panel". The matching skill fires and calls the verbs. You can run them yourself:
qsm-uix screenshot --out shot.png
qsm-uix crop shot.png --rect 120,40,60,24 --zoom 6 --out zoom.png
qsm-uix contrast shot.png --fg 130,48 --bg 124,44
qsm-uix region-stats shot.png --rect 120,40,60,24 --top 8
--json prints one envelope. qsm-uix doctor reports which tools are missing.
magick (imagemagick). screenshot and burst need grim.a11y-tree needs gdbus and an AT-SPI bus that answers. An audit sends keys with wtype.screenshot and burst work on Wayland only. Without --output they ask hyprctl for the focused monitor.region-stats caps at 65536 pixels.diff reports SSIM distortion, where 0 means identical.qsm-uix temp directory unless QSM_UIX_DIR or --out says otherwise.hooks/register.tsx 8 lines1// qsm-uix's function-hooks module.
2import type { On } from "claude-code";
3import { registerNoun } from "./noun.generated.ts";
4
5export function register(on: On): void {
6 registerNoun(on);
7}
8hooks/noun.generated.ts 133 lines1// Generated by lib/generate-noun.ts from qsm.json. Do not hand-edit; run
2// `bun lib/generate-noun.ts plugins/<name>` again after changing qsm.json.
3import type { Caught, EngineInterface, On } from "claude-code";
4import type { Uix } from "../types/index.d.ts";
5
6/** How one option becomes argv: a positional value (`flag` null), a bare
7 * boolean flag, or a flag and its value; a list repeats it per element. */
8export interface VerbArg {
9 readonly name: string;
10 readonly flag: string | null;
11 readonly boolean?: true;
12}
13
14export interface VerbRow {
15 readonly words: readonly string[];
16 readonly args: readonly VerbArg[];
17 readonly passthrough?: true;
18}
19
20export const verbs: Readonly<Record<string, VerbRow>> = {
21 screenshot: { words: ["qsm-uix","screenshot"], args: [{ name: "output", flag: "--output" }, { name: "out", flag: "--out" }] },
22 crop: { words: ["qsm-uix","crop"], args: [{ name: "input", flag: null }, { name: "rect", flag: "--rect" }, { name: "zoom", flag: "--zoom" }, { name: "out", flag: "--out" }] },
23 diff: { words: ["qsm-uix","diff"], args: [{ name: "a", flag: null }, { name: "b", flag: null }, { name: "out", flag: "--out" }] },
24 burst: { words: ["qsm-uix","burst"], args: [{ name: "count", flag: "--count" }, { name: "interval", flag: "--interval" }, { name: "outDir", flag: "--out-dir" }, { name: "output", flag: "--output" }] },
25 pixelSample: { words: ["qsm-uix","pixel-sample"], args: [{ name: "image", flag: null }, { name: "at", flag: "--at" }] },
26 contrast: { words: ["qsm-uix","contrast"], args: [{ name: "image", flag: null }, { name: "fg", flag: "--fg" }, { name: "bg", flag: "--bg" }] },
27 regionStats: { words: ["qsm-uix","region-stats"], args: [{ name: "image", flag: null }, { name: "rect", flag: "--rect" }, { name: "top", flag: "--top" }] },
28 a11yTree: { words: ["qsm-uix","a11y-tree"], args: [{ name: "app", flag: null }, { name: "depth", flag: "--depth" }] },
29 doctor: { words: ["qsm-uix","doctor"], args: [] },
30};
31
32/** One verb's argv: its words, each option in declaration order, the
33 * passthrough rest after `--`, and `--json` last. */
34export function argvOf(row: VerbRow, options: Readonly<Record<string, unknown>> = {}): string[] {
35 const argv = [...row.words];
36 for (const arg of row.args) {
37 const value = options[arg.name];
38 const values = Array.isArray(value) ? value : [value];
39 for (const one of values) {
40 if (one === undefined || one === false) {
41 continue;
42 }
43 argv.push(...(arg.flag === null ? [] : [arg.flag]), ...(arg.boolean ? [] : [String(one)]));
44 }
45 }
46 const rest = options.rest;
47 if (row.passthrough && Array.isArray(rest)) {
48 argv.push("--", ...rest.map(String));
49 }
50 argv.push("--json");
51 return argv;
52}
53
54/** The binary prints one outcome envelope on stdout under --json:
55 * { code, exit, reason, message, warnings, data }. Data on ok; otherwise the
56 * code and message become the error. A stale or misbehaving binary that
57 * prints something else (not JSON, or JSON with no numeric `exit`) throws
58 * loud here instead of a raw JSON.parse SyntaxError or a silent `undefined`. */
59export function unwrap(
60 argv: readonly string[],
61 result: { readonly exitCode: number; readonly stdout: string; readonly stderr: string },
62 label = argv.slice(0, 2).join(" "),
63): unknown {
64 const text = result.stdout.trim();
65 const stderr = result.stderr.trim().slice(0, 200);
66 const cause = `(exit ${result.exitCode}): ${stderr.length > 0 ? stderr : "no stderr"}`;
67 if (text.length === 0) {
68 throw new Error(`${argv.join(" ")}: printed no envelope ${cause}`);
69 }
70 let parsed: unknown;
71 try {
72 parsed = JSON.parse(text);
73 } catch {
74 parsed = undefined;
75 }
76 if (typeof parsed !== "object" || parsed === null || typeof (parsed as { exit?: unknown }).exit !== "number") {
77 throw new Error(`${argv.join(" ")}: stdout is not an outcome envelope ${cause}: ${text.slice(0, 200)}`);
78 }
79 const envelope = parsed as { code: string; exit: number; message: string; data: unknown };
80 if (envelope.exit !== 0) {
81 throw new Error(`${label}: ${envelope.code}: ${envelope.message}`);
82 }
83 return envelope.data;
84}
85
86/** What every `.catch` does first: a toast and a log for the person. The toast says `fixedReason`
87 * when the cause may carry something the toast must not show, else the cause; the cause goes to
88 * the log and comes back for the caller's own policy. */
89export function failure(
90 $: EngineInterface,
91 event: string,
92 error: Caught["error"],
93 fixedReason?: string,
94): string {
95 const said = error.message ?? error.kind;
96 $.ui.toast(`qsm-uix ERROR: ${event}: ${(fixedReason ?? said).slice(0, 120)}`);
97 $.ui.log(`${event} failed (${error.kind}): ${said}`);
98 return said;
99}
100
101/** Adds $.uix to the engine. Call once from the plugin's own
102 * `register(on, options)` — never call the engine.create hook's own
103 * `next(e)` result from anywhere but here. */
104export function registerNoun(on: On): void {
105 on("engine.create", async (_$, e, next) => {
106 const beneath = await next(e);
107 const run = async (row: VerbRow, options?: Readonly<Record<string, unknown>>): Promise<unknown> => {
108 const argv = argvOf(row, options);
109 let result: Awaited<ReturnType<typeof beneath.process.run>>;
110 try {
111 result = await beneath.process.run(argv);
112 } catch (error) {
113 // A spawn in a deleted directory fails as ENOENT naming the binary; name the directory. A
114 // check that cannot run leaves the spawn's own error as the cause.
115 const gone = await beneath.session
116 .cwd()
117 .then(async (cwd) => ((await beneath.fs.exists(cwd)) ? null : cwd))
118 .catch(() => null);
119 if (gone !== null) {
120 throw new Error(`${row.words.join(" ")}: the session's working directory ${gone} no longer exists; cd to one that does`);
121 }
122 throw error;
123 }
124 return unwrap(argv, result, row.words.join(" "));
125 };
126 const methods = Object.entries(verbs).map(([method, row]) => [
127 method,
128 (options?: Readonly<Record<string, unknown>>) => run(row, options),
129 ]);
130 return { ...beneath, uix: Object.fromEntries(methods) as Uix };
131 });
132}
133types/index.d.ts 52 lines1// Generated by lib/generate-noun.ts from qsm.json. Do not hand-edit; run
2// `bun lib/generate-noun.ts plugins/<name>` again after changing qsm.json.
3
4export type Uix = {
5 screenshot(options: {
6 output?: string;
7 out?: string;
8 }): Promise<unknown>;
9 crop(options: {
10 input: string;
11 rect: string;
12 zoom?: number;
13 out?: string;
14 }): Promise<unknown>;
15 diff(options: {
16 a: string;
17 b: string;
18 out?: string;
19 }): Promise<unknown>;
20 burst(options: {
21 count: number;
22 interval: number;
23 outDir: string;
24 output?: string;
25 }): Promise<unknown>;
26 pixelSample(options: {
27 image: string;
28 at: string;
29 }): Promise<unknown>;
30 contrast(options: {
31 image: string;
32 fg: string;
33 bg: string;
34 }): Promise<unknown>;
35 regionStats(options: {
36 image: string;
37 rect: string;
38 top?: number;
39 }): Promise<unknown>;
40 a11yTree(options: {
41 app?: string;
42 depth?: number;
43 }): Promise<unknown>;
44 doctor(): Promise<unknown>;
45};
46
47declare module 'claude-code' {
48 interface EngineInterface {
49 uix: Uix;
50 }
51}
52