## Images

### Optimize image
`POST /files/:fileId/optimize`
Body: `{ format?: "webp"|"avif", quality?: 1-100 }`
Defaults: quality 80 (WebP), 50 (AVIF). Creates a new file (original unchanged).
Returns: `{ file, originalSize, optimizedSize, savings }`
SDK: `eb.optimizeImage({ fileId, format?, quality? })`

### Transform image
`POST /files/:fileId/transform`
Body: `{ width?, height?, fit?, format?, quality?, rotate?, flip?, grayscale? }`
fit: "cover"|"contain"|"fill"|"inside"|"outside"
format: "webp"|"avif"|"png"|"jpeg"
Creates a new file (original unchanged).
Returns: `{ file, originalSize, transformedSize, transforms }`
SDK: `eb.transformImage({ fileId, width?, height?, ... })`

### Search stock photo
`GET /stock-photos?q=<query>&save=true|false`
Searches royalty-free banks in order (Pexels → Unsplash → Pixabay → Openverse) and returns the first match.
`q` is required. English queries give better results across all banks.
`save=true` stores the photo in your library and adds `fileId` + `savedUrl` (requires WRITE scope; searching alone does not).
Returns: `{ url, alt, photographer, provider, sourceUrl?, fileId?, savedUrl?, attribution }`
Costs 1 credit per call — including when the match is poor. The search is fuzzy and almost always returns *something*, so check `alt` to judge relevance instead of expecting an error.
You must display `attribution`: Unsplash and Pixabay require crediting the photographer in their terms.
SDK: `eb.searchStockPhoto({ query, save? })`
MCP: `search_stock_photo`

### Search icon
`GET /icons?q=<query>&style=outline|filled|duotone|brand&limit=<1-12>`
Searches Iconify (Lucide, Heroicons, Material Symbols, Tabler, Phosphor, Simple Icons) and returns candidates with the **SVG inline** — not a URL. Inline is the point: an external icon host is a failure point in your page's critical render path, it can't inherit `currentColor` (so it breaks dark mode and brand tokens), and it disappears when exporting to PDF.
English queries give much better results. Each SVG ships with `height:1em` and `color:currentColor`: it inherits the parent's *color* and *font-size* — not its width. Sizing the parent with `w-8 h-8` does nothing; use `text-2xl` on the container, or put `w-6 h-6` on the `<svg>` itself.
Each candidate's `name` (e.g. `lucide:heart`) is pasteable verbatim into `<span data-icon-query="lucide:heart"></span>`, which the document pipeline resolves on save.
Returns: `{ icons: [{ name, prefix, set, svg, license, trademark }], query }`
⚠️ Check `trademark`: brand logos (simple-icons) are CC0 on the *file* but the mark itself stays protected — use them only to refer to that company, never as a client's logo or as decoration, and don't distort them.
Free — 0 credits. Returns an empty `icons` array if the icon service is unreachable; it never errors.
SDK: `eb.searchIcon({ query, style?, limit? })`
MCP: `search_icon`

### Screenshot a page
`POST /screenshots`
Body: `{ html?, url?, preset?: "mobile"|"desktop", viewport?: { width, height }, fullPage?, dataId?, selector?, padding?, waitMs?, fileName? }`
Renders a page in a real Chromium and stores the PNG as a public file — the "eye" for an agent that edits sites: capture, look at it, fix, repeat.
`html` wins over `url` and is the useful one: it lets you review a draft **without publishing it**. POST (not GET) because a landing's HTML doesn't fit in a query string. Requires WRITE scope.
`preset` defaults to `mobile` (390x844) — the worst case, and where landings actually break. `viewport` overrides it.
Returns: `{ fileId, url, width, height, contentType, size, preset, broken, warning? }`
The emulation is real: a `mobile` preset carries device pixel density and touch, not just a narrow viewport. `waitMs` is honored, and the box additionally waits for images and `document.fonts.ready` before capturing.
`warning` appears when the image came out a single flat color. That means the CSS hadn't painted — **not** that you broke the page. Don't undo your work over it; raise `waitMs` and retry.
Only public URLs: private and loopback addresses are rejected.
`dataId` (or `selector`) crops the capture to a single element, with `padding` px of context around it (default 16). Looking is expensive: a full-page mobile landing is 1170x2532, and a card crop is ~400x300. This is the second half of the audit loop — for each `incomplete` an audit returns, crop that `dataId` and look at it. The padding matters: when text sits on an image, the background **is** the thing being judged.
Costs 1 credit per call.
SDK: `eb.screenshot({ html?, url?, preset? })`
MCP: `screenshot_url`

### Audit a page (accessibility + layout)
`POST /audits`
Body: `{ html?, url?, viewports?: { name, width, height, deviceScaleFactor?, isMobile? }[], waitMs? }`
Measures a page instead of guessing about it: axe-core runs against the **painted DOM**, so contrast is computed from the colors actually rendered, and layout is measured at every viewport.
Defaults to three viewports: mobile 390, tablet 768, desktop 1440.
Returns per viewport: `axe.violations`, `axe.incomplete`, and `layout.findings`.
Read `violations` and `incomplete` separately — `incomplete` is what axe **cannot decide alone** (text over a gradient or an image). Verify those by looking at a screenshot; don't report them as failures and don't ignore them.
`layout.findings` covers `horizontal-overflow` (with `measured.overflowPx`), `text-clipped`, `overlap`, and `missing-viewport-meta`. Each finding carries `dataId`, so you can patch that exact node instead of rewriting the page.
Costs 1 credit per viewport.
MCP: `audit_page`

### Node-level editing (`data-id` addressing)
Edit a page **by node** instead of re-emitting it. What you don't touch stays **byte-identical**.
This matters beyond cost: re-emitting 40 KB to move one sentence is slow, but the real problem is that every full rewrite is a chance to change things nobody asked for.
The core is a pure HTML→HTML function — `stampIds`, `applyPatches`, `indexNodes` in `@easybits.cloud/html-tailwind-generator/htmlPatch` — so it works the same whether your pages live in EasyBits or in your own database. The MCP tools take `documentId`+`pageId` (applies and saves) **or** raw `html` (returns the result, saves nothing).
Implementation note: it uses parse5 as an *index* over the original string and never re-serializes. A DOM round-trip (jsdom, `innerHTML`) normalizes quotes, reorders attributes and closes implicit tags — it would dirty the diff of a page nobody asked to touch, and can break `<script>` blocks.
Ops: `replace` · `remove` · `insert` with `pos`: `append`/`prepend` (inside the anchor) or `before`/`after` (as a sibling). To add one item to a list or grid, insert on the list — never re-emit the parent.
Returns `{ applied: string[], failed: [{ nodeId, reason }] }` — it never fails silently. `reason` ∈ `missing` | `ambiguous` (two nodes share the id — editing the wrong one is worse than not editing) | `unparseable` | `root` | `void` | `empty`. A patch that doesn't apply leaves the document untouched.
Ids are deterministic by position, so re-stamping is idempotent and an outline you already fetched stays valid.
MCP: `get_node_outline`, `patch_node`
