SLOPSHOPPER

qsm-uix

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

newprocess
A shopper browsing a rack in a slop shop
README

qsm-uix

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.

Why it exists

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.

What you get

KindNameWhat it does
Skillui-designWrites a stack-neutral design source: tokens, on-pairs, light and dark themes, a style with its accessibility rules
Skillui-auditMeasures static visuals: spacing, geometry, colour, contrast, type, icons, elevation, theme parity
Skillux-auditTraces behaviour: discoverability, feedback, targets, motion, reversibility, error recovery, forms
Skilla11y-auditDrives the UI by keyboard and the accessibility tree, and fills a per-criterion conformance table
Skilla11y-conformanceGives the WCAG 2.2 A and AA criteria and the law behind them (European Accessibility Act, EN 301 549)
Skillqml-uiQuickshell and QML on Hyprland: Theme singleton, item-tree dump, qmllint and qmlformat
Skillcss-uiPlain CSS and HTML, with a short Tailwind section
Skillreact-uiWhat React leaves to you: focus and aria, forms, loading states
Skilltui-uiTerminal output: colour tiers, NO_COLOR, width, non-TTY output, progress
AgentvisualizerRenders findings or other structured data as one self-contained HTML page
CLIqsm-uix9 verbs that turn a screen into numbers, see USAGE.md

Install

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.

Use

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.

Caveats

  • Every measuring verb needs 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.
  • A region past the image edge is refused, not clipped. region-stats caps at 65536 pixels.
  • diff reports SSIM distortion, where 0 means identical.
  • Files go to a qsm-uix temp directory unless QSM_UIX_DIR or --out says otherwise.
Source 3 files
hooks/register.tsx 8 lines
1// 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}
8
hooks/noun.generated.ts 133 lines
1// 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}
133
types/index.d.ts 52 lines
1// 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