ui-repair

A linter says the file has a raw button and #8B5CF6. ui-repair applies system rules and writes a patch. On the fixture a 10-check rubric moves from 50 to 100. That is not a model call and not a ds-eval run.

Why

Finding a violation is the small half. The team needs a patch: a raw <button> becomes Button, #8B5CF6 becomes a token, a homemade modal becomes Dialog. ui-repair makes that replacement with rules and shows which check flipped.

A live agent should emit a patch of the same shape. This MVP keeps the rules deterministic so the example runs without a key.

Rules

Each rule reads the fixture text and the tokens.css beside it. On Acme, --color-accent is #8B5CF6 and --space-3 is 12px.

FindsWrites
a hex that matches a token valuevar(--color-accent)
padding: 13var(--space-3)
<div className="modal"><Dialog>
a raw <button><Button> and an import from @ds
a button whose only child is ×aria-label="Close"

The import is one line, added when the file has no from "@ds" yet: import { Button, Dialog } from "@ds";

Run

python3 ui-repair/ui_repair.py scan ui-repair/fixtures
python3 ui-repair/ui_repair.py fix ui-repair/fixtures --out ui-repair/examples

scan prints the list and writes nothing. fix puts Settings.jsx in ui-repair/examples and prints the local score before and after. On the fixture, scan reports:

6 issues
  raw <button>
  hardcoded hex color
  hand-rolled modal
  padding 13 is off the space scale
  icon button without an accessible name
  no design-system import

Before

ui-repair/fixtures/Settings.jsx. A purple background as a number, padding 13, Save as a raw button, a div modal, and a close mark with no name.

export function Settings() {
  const open = () => {};
  return (
    <section>
      <h1>Settings</h1>
      <div style={{ background: "#8B5CF6", padding: 13 }}>
        <button onClick={open}>Save</button>
        <div className="modal">
          <h2>Delete account</h2>
          <button>×</button>
        </div>
      </div>
    </section>
  );
}

After

The same file after fix. This is the rule result, not a model reply.

import { Button, Dialog } from "@ds";
export function Settings() {
  const open = () => {};
  return (
    <section>
      <h1>Settings</h1>
      <div style={{ background: "var(--color-accent)", padding: "var(--space-3)" }}>
        <Button onClick={open}>Save</Button>
        <Dialog>
          <h2>Delete account</h2>
          <Button aria-label="Close">×</Button>
        </Dialog>
      </div>
    </section>
  );
}

Checks

Ten checks, equal weight. Before the repair the score is 50: five structural checks already passed, five system checks did not. After, it is 100.

CheckBeforeAfter
exports a componentpasspass
handles a clickpasspass
no dangerouslySetInnerHTMLpasspass
has a headingpasspass
no !importantpasspass
imports the design systemfailpass
no raw buttonfailpass
no hardcoded hexfailpass
uses Dialog, not a modal divfailpass
no magic padding numberfailpass

This is not a ds-eval score and not a Claude result. The next step is to run the patch through that harness: build, axe, Playwright. What you see here is a rule repair and which checks it closed.

Where it sits

ds-context says which components and tokens exist. ui-repair moves a file onto that list. ds-eval checks whether the screen works, not only whether the tags look clean.

Limit

The rules know this fixture: one hex, padding 13, a modal class, a close mark. They will not repair an arbitrary codebase. There is no git-style diff, no screenshot, and no live agent. A jump from 50 to 100 is easy to read and easy to over-read.