feat(sketch): Hanzo AI for Sketch — native design-AI plugin

@hanzo/sketch — a Sketch plugin adapter over the shared Hanzo design-AI
action catalog, mirroring @hanzo/figma/@hanzo/canva. Five commands:
summarize/critique, rewrite, translate, content-fill, ask.

- design-core.ts (PURE, 43 vitest tests): the action catalog, selection
  context assembly/truncation, @hanzo/ai request shaping (createAiClient,
  @hanzo/ai@^0.2.0), and per-layer / prose response parsing.
- sketch-bridge.cjs: the only sketch/dom boundary — readSelection,
  applyLayerEdits (layer.text = …), insertNote.
- command.cjs: opens the WebView, bridges messages; key stored via
  sketch/settings (never bundled/committed).
- webview/: HTML + TS panel that runs @hanzo/ai (model call where fetch
  exists), previews edits, applies them back.
- build.js: assembles the .sketchplugin bundle (esbuild, two runtimes).

Model calls route through api.hanzo.ai /v1 via @hanzo/ai only.
This commit is contained in:
Hanzo Dev
2026-07-03 21:07:07 -07:00
parent b24f0eb76e
commit c262c6e506
15 changed files with 1644 additions and 0 deletions
+117
View File
@@ -0,0 +1,117 @@
# Hanzo AI for Sketch
`@hanzo/sketch` — a native Sketch plugin that puts Hanzo AI on your macOS design
canvas. Critique a selection, rewrite / translate / content-fill text layers, and
ask about your design, without leaving Sketch.
It is a **thin adapter over the shared Hanzo design-AI action catalog** — the same
prompt builders, context assembly, and response parsing that back
[`@hanzo/figma`](../) and `@hanzo/canva`, expressed once as the PURE
[`design-core.ts`](src/design-core.ts). Model calls route through the
`api.hanzo.ai` gateway via the published [`@hanzo/ai`](https://www.npmjs.com/package/@hanzo/ai)
headless client. No `/api/` prefix, ever — always `/v1/...`.
## Design actions
| Command | What it does | Write-back |
| -------------------------------- | --------------------------------------------------------------------------- | --------------------- |
| **Summarize / critique selection** | UX/copy/hierarchy critique of the selection, leading with a one-line take. | Inserts a note layer |
| **Rewrite selected text** | Rewrites each text layer's copy (optionally per an instruction). | `layer.text = …` |
| **Translate text layers** | Translates each text layer into a target language. | `layer.text = …` |
| **Content fill** | Replaces each text layer with realistic, role-fitting placeholder copy. | `layer.text = …` |
| **Ask Hanzo…** | Free-form question about the selection. | Inserts a note layer |
`rewrite`, `translate`, and `contentfill` are **per-layer**: the model returns a
JSON array positionally aligned to the selected layers, parsed by
`parseLayerEdits` into `{ id, name, text }` edits the panel previews before you
**Apply to layers**. `critique` and `ask` return prose you can **Add note to
canvas** as a new Text layer next to the selection.
## Selection read → write-back flow
```
Sketch selection
│ command.js → sketch-bridge.readSelection() (sketch/dom)
Selection { layers: TextLayer[], totalCount }
│ seeded into the WebView (window.__hanzoSeed)
design-core.runAction() → @hanzo/ai createAiClient().chat.completions.create()
│ (the model call runs in the WebView — it has fetch)
raw text
├─ per-layer → parseLayerEdits(raw, layers) → LayerEdit[]
│ window.postMessage('applyEdits') → sketch-bridge.applyLayerEdits()
│ layer.text = edit.text
└─ prose → resultNote(id, raw) → { heading, body }
window.postMessage('insertNote') → sketch-bridge.insertNote()
new sketch.Text(...)
```
`sketch-bridge.js` is the ONLY file that touches `sketch/dom`; `design-core.ts`
is pure and fully unit-tested.
## Your Hanzo key
The plugin never bundles or commits a key. Open the panel, expand **Hanzo key &
model**, paste an `hk-…` Hanzo API key (or an IAM access token from
[`@hanzo/iam`](https://www.npmjs.com/package/@hanzo/iam)), and **Save**. It is
stored per-plugin with `sketch/settings`. Optionally set a model (default
`zen5`).
## Build
```bash
pnpm install # from the monorepo root
pnpm --filter @hanzo/sketch build # → dist/Hanzo AI.sketchplugin
pnpm --filter @hanzo/sketch watch # rebuild on change
pnpm --filter @hanzo/sketch test # vitest
pnpm --filter @hanzo/sketch typecheck # tsc --noEmit
```
`build.js` assembles the `.sketchplugin` bundle in two esbuild passes:
`command.js``Contents/Sketch/` as CommonJS for Sketch's CocoaScript runtime
(`sketch/*` external), and the WebView `app.ts` (with `@hanzo/ai` bundled) →
`Contents/Resources/app.js` as a browser IIFE, alongside `webview.html`, the
`manifest.json`, and the icon.
## Install
- **Local:** run the build, then double-click `dist/Hanzo AI.sketchplugin`.
Sketch installs it into
`~/Library/Application Support/com.bohemiancoding.sketch3/Plugins/`.
- The commands appear under **Plugins → Hanzo AI** with the shortcuts declared in
`manifest.json` (⌃⇧K critique, ⌃⇧R rewrite, ⌃⇧T translate, ⌃⇧F content-fill,
⌃⇧A ask).
## Publish to the Sketch plugin directory
The build produces a standards-compliant `.sketchplugin` bundle with a
`manifest.json` (`identifier: ai.hanzo.sketch`, `appcast`, `bundleVersion`).
To publish to the [Sketch plugin directory](https://www.sketch.com/extensions/plugins/):
1. Host the built `.sketchplugin` and a Sparkle `appcast.xml` (the `appcast` URL
in the manifest) so Sketch can auto-update installs.
2. Tag a release in the plugin's GitHub repo containing the built bundle.
3. Submit the repo via the Sketch extensions submission form; Sketch reviews and
lists it. Bump `version`/`bundleVersion` in `manifest.json` on each release —
patch bumps only.
## Layout
```
src/
manifest.json Sketch plugin manifest — 5 commands + menu
command.cjs plugin entry (CommonJS, CocoaScript runtime): opens the WebView, bridges messages
sketch-bridge.cjs sketch/dom read (readSelection) + write-back (applyLayerEdits, insertNote)
design-core.ts PURE: action catalog, context assembly/truncation, @hanzo/ai shaping, parsing
webview/
index.html the assistant panel
app.ts panel logic over design-core + @hanzo/ai (built to Resources/app.js)
build.js assembles the .sketchplugin bundle
tests/ vitest — the pure design-core + bridge helpers
```
`command.cjs` and `sketch-bridge.cjs` are CommonJS (`.cjs`) because they run on
Sketch's JavaScriptCore/CocoaScript runtime, not Node ESM. `build.js` emits them
as `Contents/Sketch/command.js` for the manifest to reference.
Binary file not shown.

After

Width:  |  Height:  |  Size: 422 B

+110
View File
@@ -0,0 +1,110 @@
// Assemble the .sketchplugin bundle into dist/. A Sketch plugin is a macOS
// bundle laid out as:
//
// Hanzo AI.sketchplugin/
// Contents/
// Sketch/
// manifest.json ← commands + menu
// command.js ← plugin entry (CommonJS, CocoaScript runtime)
// Resources/
// webview.html ← the assistant panel
// app.js ← the WebView bundle (browser, @hanzo/ai)
// icon.png
//
// node build.js → production build
// node build.js --watch → rebuild on change
//
// Two esbuild passes because the two runtimes differ: command.js runs on
// Sketch's JavaScriptCore/CocoaScript (CommonJS, `require('sketch/*')` external),
// and the WebView bundle runs in a real WebView (browser, `fetch` available, so
// @hanzo/ai is bundled in).
import esbuild from 'esbuild';
import fs from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
const watch = process.argv.includes('--watch');
const root = path.dirname(fileURLToPath(import.meta.url));
const src = path.join(root, 'src');
const dist = path.join(root, 'dist');
const bundle = path.join(dist, 'Hanzo AI.sketchplugin');
const sketchDir = path.join(bundle, 'Contents', 'Sketch');
const resDir = path.join(bundle, 'Contents', 'Resources');
// The `sketch` runtime modules are provided by the Sketch app — never bundled.
const SKETCH_EXTERNALS = [
'sketch',
'sketch/dom',
'sketch/ui',
'sketch/settings',
'sketch-module-web-view',
];
async function build() {
fs.rmSync(dist, { recursive: true, force: true });
fs.mkdirSync(sketchDir, { recursive: true });
fs.mkdirSync(resDir, { recursive: true });
// 1) The plugin entry — CommonJS for CocoaScript. sketch-bridge + design-core
// (its pure exports) are bundled in; the sketch/* runtime stays external.
// design-core imports @hanzo/ai, but command.js never calls runAction (the
// model call is in the WebView), so tree-shaking drops @hanzo/ai here.
const commandCtx = await esbuild.context({
entryPoints: [path.join(src, 'command.cjs')],
outfile: path.join(sketchDir, 'command.js'),
bundle: true,
format: 'cjs',
platform: 'node',
target: ['es2017'],
external: SKETCH_EXTERNALS,
sourcemap: false,
minify: !watch,
logLevel: 'info',
});
await commandCtx.rebuild();
// 2) The WebView bundle — browser IIFE, @hanzo/ai bundled in (it needs fetch,
// which the WebView has). design-core rides along.
const webCtx = await esbuild.context({
entryPoints: [path.join(src, 'webview', 'app.ts')],
outfile: path.join(resDir, 'app.js'),
bundle: true,
format: 'iife',
platform: 'browser',
target: ['safari14'],
sourcemap: false,
minify: !watch,
logLevel: 'info',
});
await webCtx.rebuild();
// 3) Static: manifest → Sketch/, webview HTML + icon → Resources/.
fs.copyFileSync(path.join(src, 'manifest.json'), path.join(sketchDir, 'manifest.json'));
fs.copyFileSync(path.join(src, 'webview', 'index.html'), path.join(resDir, 'webview.html'));
const icon = path.join(root, 'assets', 'icon.png');
if (fs.existsSync(icon)) {
fs.copyFileSync(icon, path.join(resDir, 'icon.png'));
} else {
console.log(' ⚠ assets/icon.png missing — bundle builds, but add an icon before publishing.');
}
console.log(`✅ Built ${path.relative(root, bundle)}`);
console.log(' Install: double-click the .sketchplugin, or copy it into');
console.log(' ~/Library/Application Support/com.bohemiancoding.sketch3/Plugins/');
if (watch) {
await commandCtx.watch();
await webCtx.watch();
console.log('👀 watching…');
} else {
await commandCtx.dispose();
await webCtx.dispose();
}
}
build().catch((e) => {
console.error(e);
process.exit(1);
});
+28
View File
@@ -0,0 +1,28 @@
{
"name": "@hanzo/sketch",
"version": "0.1.0",
"description": "Hanzo AI for Sketch — critique a selection, rewrite/translate/content-fill text layers, and ask about your design, all inside Sketch. A thin macOS-native adapter over the shared Hanzo design-AI action catalog. Model calls route through the api.hanzo.ai gateway via @hanzo/ai; the Hanzo key is stored with sketch/settings.",
"private": true,
"type": "module",
"scripts": {
"build": "node build.js",
"watch": "node build.js --watch",
"test": "vitest run",
"typecheck": "tsc --noEmit"
},
"dependencies": {
"@hanzo/ai": "^0.2.0",
"@hanzo/iam": "^0.13.2"
},
"devDependencies": {
"@types/node": "^20.14.0",
"esbuild": "^0.25.8",
"sketch-module-web-view": "^3.5.3",
"typescript": "^5.8.3",
"vitest": "^3.2.6"
},
"engines": {
"node": ">=18.0.0"
},
"license": "MIT"
}
+143
View File
@@ -0,0 +1,143 @@
// Plugin entry — one handler per manifest command. Each command reads the
// current selection (sketch-bridge → sketch/dom), opens the assistant WebView
// with that selection + the stored Hanzo key, and preselects the action. The
// WebView runs @hanzo/ai (the model call happens in the WebView, which has
// `fetch`), then posts results back over the bridge; this file writes them onto
// the document (layer.text = … / insert a note) via sketch-bridge.
//
// The Hanzo key is stored with `sketch/settings` (Sketch's per-plugin settings
// store) — never in the bundle, never in git.
var sketch = require('sketch');
var UI = require('sketch/ui');
var settings = require('sketch/settings');
var BrowserWindow = require('sketch-module-web-view');
var bridge = require('./sketch-bridge.cjs');
// The panel is a single WebView; opening a different action just re-focuses it
// and swaps the active action. One window identity so a user never stacks
// duplicate panels.
var WINDOW_IDENTIFIER = 'hanzo.sketch.assistant';
// Where the Hanzo key lives in the plugin settings store.
var KEY_SETTING = 'hanzoApiKey';
var MODEL_SETTING = 'hanzoModel';
function getKey() {
return settings.settingForKey(KEY_SETTING) || '';
}
function getModel() {
return settings.settingForKey(MODEL_SETTING) || '';
}
// open builds (or re-focuses) the assistant WebView for a given action, seeds it
// with the current selection + stored key/model, and wires the message bridge.
function open(context, action) {
var selection = bridge.readSelection();
if (!selection.layers.length && (action === 'rewrite' || action === 'translate' || action === 'contentfill')) {
UI.message('Select one or more text layers first.');
return;
}
var existing = BrowserWindow.fromId(WINDOW_IDENTIFIER);
var win =
existing ||
new BrowserWindow({
identifier: WINDOW_IDENTIFIER,
title: 'Hanzo AI',
width: 420,
height: 640,
minWidth: 360,
minHeight: 420,
show: false,
alwaysOnTop: true,
webPreferences: { devTools: false },
});
var webContents = win.webContents;
// Bridge: the WebView asks the plugin to persist the key, to apply per-layer
// edits, or to insert a note. Registered once per window open; safe to
// re-register (replaces the prior handler) when re-focusing.
webContents.on('saveKey', function (payload) {
settings.setSettingForKey(KEY_SETTING, (payload && payload.key) || '');
if (payload && typeof payload.model === 'string') {
settings.setSettingForKey(MODEL_SETTING, payload.model);
}
UI.message('Hanzo key saved.');
});
webContents.on('applyEdits', function (payload) {
var edits = (payload && payload.edits) || [];
var n = bridge.applyLayerEdits(edits);
UI.message(n + ' layer' + (n === 1 ? '' : 's') + ' updated.');
webContents.executeJavaScript('window.__hanzoApplied && window.__hanzoApplied(' + n + ')');
});
webContents.on('insertNote', function (payload) {
if (payload && payload.note) {
bridge.insertNote(payload.note);
UI.message('Note added to canvas.');
}
});
webContents.on('refreshSelection', function () {
seed(webContents, bridge.readSelection(), action);
});
win.loadURL(webviewURL(context));
// Seed after the page is ready so the WebView has the selection/action/key
// before it renders.
webContents.on('did-finish-load', function () {
seed(webContents, selection, action);
win.show();
});
if (existing) {
// Already loaded — seed immediately and refocus.
seed(webContents, selection, action);
win.show();
}
}
// seed pushes the runtime state into the WebView: the selection to act on, the
// preselected action, and the stored key/model. The WebView owns the model call
// from here.
function seed(webContents, selection, action) {
var payload = {
selection: selection,
action: action,
key: getKey(),
model: getModel(),
};
webContents.executeJavaScript(
'window.__hanzoSeed && window.__hanzoSeed(' + JSON.stringify(payload) + ')',
);
}
// webviewURL resolves the bundled webview.html inside the .sketchplugin
// Resources dir. context.plugin.urlForResourceNamed is the Sketch API for it.
function webviewURL(context) {
var url = context.plugin.urlForResourceNamed('webview.html');
return url ? url.absoluteString() : '';
}
// One exported handler per manifest command. Names match manifest.json.
module.exports = {
onCritique: function (context) {
open(context, 'critique');
},
onRewrite: function (context) {
open(context, 'rewrite');
},
onTranslate: function (context) {
open(context, 'translate');
},
onContentFill: function (context) {
open(context, 'contentfill');
},
onAsk: function (context) {
open(context, 'ask');
},
};
+323
View File
@@ -0,0 +1,323 @@
// The Hanzo AI design-action catalog — the SAME actions the @hanzo/figma and
// @hanzo/canva adapters run, expressed once as PURE functions over the
// published @hanzo/ai contract. A design action = build a task prompt + a
// selection context, ask Hanzo through createAiClient, and (for the write
// actions) parse the response into text/layer edits the host writes back.
//
// Everything here is pure and unit-tested. The two async edges — reading the
// Sketch selection (sketch/dom) and writing results back (layer.text = …) —
// live in sketch-bridge.cjs and the WebView; they call into this core so the
// prompt shaping, context assembly/truncation, and response parsing are
// testable without a Sketch runtime or a network.
import {
createAiClient,
type AiClient,
type ChatCompletion,
type ChatCompletionMessage,
} from '@hanzo/ai';
// ── Gateway defaults ─────────────────────────────────────────────────────────
/** The Hanzo model gateway. @hanzo/ai defaults here too; never an `/api/` prefix. */
export const HANZO_API_BASE_URL = 'https://api.hanzo.ai';
/** Default model when the user has not picked one. A Zen model (qwen3+). */
export const DEFAULT_MODEL = 'zen5';
// ── Selection context (pure) ─────────────────────────────────────────────────
/**
* One text-bearing layer as this adapter consumes it: the layer's id (so a
* write-back targets the exact layer), its name, and its text. sketch-bridge.cjs
* extracts these from `document.selectedLayers` (Text layers → `.text`); the
* core never touches sketch/dom.
*/
export interface TextLayer {
id: string;
name: string;
text: string;
}
/**
* The design selection handed to an action: the text layers plus a coarse count
* of everything selected (so "critique" can note non-text layers — shapes,
* groups, symbols — without their pixels).
*/
export interface Selection {
layers: TextLayer[];
/** Total selected layers, including non-text ones. */
totalCount: number;
}
// The model's context window is finite; a design file's copy can be large. Cap
// the assembled context so a big selection never blows the request. Sized to
// sit comfortably in a modern context window alongside the response.
export const MAX_CONTEXT_CHARS = 24_000;
// truncate cuts text to a max length, appending a visible marker so the model
// (and the reviewing user) know content was elided. Pure.
export function truncate(text: string, max: number): string {
if (text.length <= max) return text;
return text.slice(0, max) + `\n…[truncated ${text.length - max} chars]`;
}
/**
* assembleContext renders a Selection into the single string handed to Hanzo:
* each text layer as a `[n] "Name": text` line so the model can refer back to a
* layer by index and name. Non-text layers are summarized as a trailing count.
* The whole thing is capped at maxChars (layers first, so leading copy survives
* truncation). Pure — unit-tested.
*/
export function assembleContext(sel: Selection, maxChars = MAX_CONTEXT_CHARS): string {
const lines = sel.layers.map(
(l, i) => `[${i + 1}] ${l.name ? `"${l.name}"` : '(unnamed)'}: ${l.text}`,
);
const nonText = sel.totalCount - sel.layers.length;
if (nonText > 0) {
lines.push(
`(+ ${nonText} non-text layer${nonText === 1 ? '' : 's'} selected: shapes/groups/symbols)`,
);
}
return truncate(lines.join('\n'), maxChars);
}
// ── System frame + message shaping (pure) ────────────────────────────────────
export const SYSTEM_PROMPT =
'You are Hanzo AI working inside Sketch, a macOS design tool used by agencies ' +
'and product teams. You are given the text content of a design selection (its ' +
'layers) and a task. Work only from the provided content — never invent facts ' +
'not present in it. Write like a senior product designer and copywriter: ' +
'concise, concrete, on-brand. Return clean text with no preamble, no ' +
'"Here is", and no code fences unless the task explicitly asks for structured ' +
'output.';
/**
* buildMessages composes the system frame + the selection context (fenced as
* data, so the model treats layer copy as data not instructions — a
* prompt-injection boundary) + the task instruction. Pure — unit-tested.
*/
export function buildMessages(task: string, context: string): ChatCompletionMessage[] {
const trimmed = context.trim();
const user: ChatCompletionMessage = {
role: 'user',
content: trimmed
? `${task}\n\n---- design selection ----\n${trimmed}\n---- end ----`
: task,
};
return [{ role: 'system', content: SYSTEM_PROMPT }, user];
}
// ── The design action catalog (pure task strings) ────────────────────────────
/**
* The five design actions, mirroring the Figma/Canva action set:
* - critique → summarize + critique the selection (UX/copy/hierarchy)
* - rewrite → rewrite the selected text (per an optional instruction)
* - translate → translate the text layers into a target language
* - contentfill→ generate realistic placeholder copy for the layers
* - ask → free-form question about the selection
*/
export type ActionId = 'critique' | 'rewrite' | 'translate' | 'contentfill' | 'ask';
/** The stable list of actions, in menu order — the single source for both the
* manifest commands and the WebView UI. */
export const ACTIONS: ActionId[] = ['critique', 'rewrite', 'translate', 'contentfill', 'ask'];
/** Whether an action rewrites the per-layer text (vs. returning a single note).
* `rewrite`, `translate`, and `contentfill` produce one replacement string per
* input layer; `critique` and `ask` return a single prose note. Pure. */
export function isPerLayer(id: ActionId): boolean {
return id === 'rewrite' || id === 'translate' || id === 'contentfill';
}
// A shared instruction appended to per-layer actions: return the results as a
// JSON array of strings, positionally aligned to the input layers, so the host
// can map result[i] → layers[i].text. Kept in ONE place (DRY) since three
// actions share it.
const PER_LAYER_JSON_RULE =
'Return ONLY a JSON array of strings — one element per numbered layer above, ' +
'in the same order. Element i is the new text for layer [i+1]. Preserve a ' +
'layer exactly (echo its text) when it should not change. No prose, no code ' +
'fences — just the JSON array.';
/**
* actionTask maps an action id (+ optional user detail) to its task instruction.
* `rewrite`/`translate`/`ask` take a user detail (the instruction / target
* language / question); `critique` and `contentfill` are fixed. Pure.
*/
export function actionTask(id: ActionId, detail = ''): string {
const d = detail.trim();
switch (id) {
case 'critique':
return (
'Critique this design selection. Cover copy clarity, visual/information ' +
'hierarchy, tone, and consistency. Lead with a one-sentence overall ' +
'take, then a short bullet list of the highest-leverage improvements. ' +
'Be specific and reference layers by their name or number. No preamble.'
);
case 'rewrite':
return (
(d
? `Rewrite each layer's text so that: ${d}.`
: 'Rewrite each layer\'s text to be clearer, tighter, and more ' +
'compelling while preserving its meaning and role.') +
` ${PER_LAYER_JSON_RULE}`
);
case 'translate':
return (
`Translate each layer's text into ${d || 'Spanish'}. Keep the tone, ` +
'keep product/brand names untranslated, and match string length where ' +
`UI space is tight. ${PER_LAYER_JSON_RULE}`
);
case 'contentfill':
return (
'Replace each layer\'s text with realistic, on-brand placeholder copy ' +
'that fits its apparent role (heading, body, label, button) and roughly ' +
`its current length. ${PER_LAYER_JSON_RULE}`
);
case 'ask':
return d || 'What is this selection communicating, and what would you improve?';
}
}
// ── @hanzo/ai request shaping + running an action ────────────────────────────
/**
* Extract assistant text from a @hanzo/ai ChatCompletion. The published client
* types `content` as `string | ChatCompletionContentPart[]`; we join text parts
* and ignore images (a design critique response is text). Throws on empty
* content so the caller surfaces a real gateway failure. Pure — unit-tested.
*/
export function extractContent(res: ChatCompletion): string {
const content = res?.choices?.[0]?.message?.content;
let text = '';
if (typeof content === 'string') {
text = content;
} else if (Array.isArray(content)) {
text = content
.filter((p): p is { type: 'text'; text: string } => p?.type === 'text')
.map((p) => p.text)
.join('');
}
if (text.trim() === '') throw new Error('Hanzo API returned no content');
return text;
}
export interface RunActionParams {
id: ActionId;
/** rewrite instruction / target language / question. */
detail?: string;
selection: Selection;
/** An `hk-…` Hanzo API key or IAM access token. */
hanzoToken: string;
model?: string;
baseUrl?: string;
/** Injected in tests. */
client?: AiClient;
signal?: AbortSignal;
}
/**
* runAction assembles the context, forms the task, and asks Hanzo through the
* published createAiClient contract. Returns the RAW model text: for `critique`/
* `ask` that is a prose note; for `rewrite`/`translate`/`contentfill` it is the
* JSON array the caller parses (parseLayerEdits) into per-layer replacements.
* Writing back is a separate explicit step so the user reviews before it lands.
* This is the SINGLE path from the adapter to the model.
*/
export async function runAction(p: RunActionParams): Promise<string> {
const client =
p.client ??
createAiClient({ token: p.hanzoToken, baseUrl: p.baseUrl ?? HANZO_API_BASE_URL });
const context = assembleContext(p.selection);
const task = actionTask(p.id, p.detail);
const params = {
model: p.model || DEFAULT_MODEL,
messages: buildMessages(task, context),
stream: false as const,
};
// Pass the options object only when there's a signal — an empty options bag is
// noise on the wire and in the call trace.
const res = p.signal
? await client.chat.completions.create(params, { signal: p.signal })
: await client.chat.completions.create(params);
return extractContent(res);
}
// ── Response parsing (pure) ──────────────────────────────────────────────────
/**
* A single per-layer edit: which layer (by id) gets which new text. The host
* applies these as `layer.text = edit.text`. `id` is carried through from the
* input Selection so the write-back is unambiguous even if the model reorders.
*/
export interface LayerEdit {
id: string;
name: string;
text: string;
}
/**
* parseLayerEdits turns a per-layer action's JSON-array output into LayerEdit[],
* positionally aligned to the input layers. It tolerates a stray ```json fence
* or surrounding prose by extracting the first `[ … ]` span, coerces each
* element to a string, and pairs result[i] with layers[i]. Extra results are
* dropped; missing ones leave that layer untouched (not in the output). Throws
* only when no array is present at all, so the caller surfaces a real failure
* rather than silently writing nothing. Pure — unit-tested.
*/
export function parseLayerEdits(raw: string, layers: TextLayer[]): LayerEdit[] {
const start = raw.indexOf('[');
const end = raw.lastIndexOf(']');
if (start === -1 || end === -1 || end < start) {
throw new Error('No layer-edit array found in model output');
}
let parsed: unknown;
try {
parsed = JSON.parse(raw.slice(start, end + 1));
} catch {
throw new Error('Model output was not valid JSON');
}
if (!Array.isArray(parsed)) throw new Error('Model output was not a JSON array');
const edits: LayerEdit[] = [];
const n = Math.min(parsed.length, layers.length);
for (let i = 0; i < n; i++) {
const el = parsed[i];
const text = typeof el === 'string' ? el : el == null ? '' : String(el);
edits.push({ id: layers[i].id, name: layers[i].name, text });
}
return edits;
}
/**
* A note produced by a non-per-layer action — a heading + the model's prose.
* The host inserts this as a new text layer next to the selection so the
* critique/answer is visible on the canvas.
*/
export interface ResultNote {
heading: string;
body: string;
}
/** noteHeading — a concise, action-labelled heading for an inserted note layer. */
export function noteHeading(id: ActionId): string {
const label: Record<ActionId, string> = {
critique: 'Critique',
rewrite: 'Rewrite',
translate: 'Translation',
contentfill: 'Content',
ask: 'Answer',
};
return `Hanzo AI — ${label[id]}`;
}
/**
* resultNote builds the ResultNote for a prose action (`critique`/`ask`): a
* labelled heading plus the trimmed body. Pure — unit-tested.
*/
export function resultNote(id: ActionId, body: string): ResultNote {
return { heading: noteHeading(id), body: body.trim() };
}
+55
View File
@@ -0,0 +1,55 @@
{
"$schema": "https://raw.githubusercontent.com/sketch-hq/SketchAPI/develop/docs/manifest.schema.json",
"name": "Hanzo AI",
"identifier": "ai.hanzo.sketch",
"version": "0.1.0",
"description": "Hanzo AI for Sketch — critique a selection, rewrite/translate/content-fill text layers, and ask about your design. Model calls route through the api.hanzo.ai gateway via @hanzo/ai.",
"author": "Hanzo AI",
"authorEmail": "hi@hanzo.ai",
"homepage": "https://hanzo.ai",
"appcast": "https://sketch.hanzo.ai/appcast.xml",
"bundleVersion": 1,
"icon": "assets/icon.png",
"scope": "document",
"commands": [
{
"name": "Summarize / critique selection",
"identifier": "critique",
"shortcut": "ctrl shift k",
"script": "command.js",
"handler": "onCritique"
},
{
"name": "Rewrite selected text",
"identifier": "rewrite",
"shortcut": "ctrl shift r",
"script": "command.js",
"handler": "onRewrite"
},
{
"name": "Translate text layers",
"identifier": "translate",
"shortcut": "ctrl shift t",
"script": "command.js",
"handler": "onTranslate"
},
{
"name": "Content fill",
"identifier": "contentfill",
"shortcut": "ctrl shift f",
"script": "command.js",
"handler": "onContentFill"
},
{
"name": "Ask Hanzo…",
"identifier": "ask",
"shortcut": "ctrl shift a",
"script": "command.js",
"handler": "onAsk"
}
],
"menu": {
"title": "Hanzo AI",
"items": ["critique", "rewrite", "translate", "contentfill", "ask"]
}
}
+138
View File
@@ -0,0 +1,138 @@
// The Sketch/dom boundary: read the selection into the plain shapes design-core
// consumes (TextLayer/Selection), and write action results back onto the
// document (layer.text = …, insert a note layer). Everything the model cares
// about is a plain object here, so design-core stays pure and testable; this
// file is the ONLY place that touches the `sketch/dom` API.
//
// Sketch plugins run on JavaScriptCore (via CocoaScript), not Node — CommonJS
// (`.cjs`), not ESM. This module is required by command.cjs; it imports
// `sketch/dom` lazily inside functions so the pure exports (collectTextLayers,
// noteFrame) can be exercised in tests with a fake layer tree.
// A Sketch Text layer reports `.type === 'Text'` and carries a `.text` string.
// Groups/Artboards/SymbolInstances have `.layers`. We flatten to just the text
// layers, in document order, because the design actions operate on copy.
function collectTextLayers(layers) {
var out = [];
walk(layers, out);
return out;
}
function walk(layers, out) {
if (!layers) return;
for (var i = 0; i < layers.length; i++) {
var layer = layers[i];
if (!layer) continue;
if (layer.type === 'Text') {
out.push({ id: layer.id, name: layer.name || '', text: layer.text || '' });
} else if (layer.layers && layer.layers.length) {
// Group / Artboard / SymbolMaster — recurse into children.
walk(layer.layers, out);
}
}
}
// readSelection turns the current document selection into the Selection shape
// design-core expects: the flattened text layers plus the total count of
// selected layers (so critique can note non-text layers). Touches sketch/dom.
function readSelection() {
var sketch = require('sketch/dom');
var document = sketch.getSelectedDocument();
if (!document) return { layers: [], totalCount: 0 };
var selected = document.selectedLayers;
var selectedLayers = selected ? selected.layers : [];
return {
layers: collectTextLayers(selectedLayers),
totalCount: selectedLayers ? selectedLayers.length : 0,
};
}
// applyLayerEdits writes per-layer text replacements back onto the document.
// Each edit carries the layer id from readSelection, so we look the layer up by
// id and set `.text`. Returns the count actually written. Touches sketch/dom.
function applyLayerEdits(edits) {
var sketch = require('sketch/dom');
var document = sketch.getSelectedDocument();
if (!document || !edits || !edits.length) return 0;
var byId = indexLayersById(document);
var written = 0;
for (var i = 0; i < edits.length; i++) {
var edit = edits[i];
var layer = byId[edit.id];
if (layer && layer.type === 'Text') {
layer.text = edit.text;
written++;
}
}
return written;
}
// indexLayersById builds an id→layer map across every page so an edit's layer
// id resolves regardless of nesting. Touches sketch/dom.
function indexLayersById(document) {
var map = {};
var pages = document.pages || [];
for (var p = 0; p < pages.length; p++) {
indexWalk(pages[p].layers, map);
}
return map;
}
function indexWalk(layers, map) {
if (!layers) return;
for (var i = 0; i < layers.length; i++) {
var layer = layers[i];
if (!layer) continue;
map[layer.id] = layer;
if (layer.layers && layer.layers.length) indexWalk(layer.layers, map);
}
}
// insertNote drops a prose result (critique/answer) onto the canvas as a new
// Text layer near the selection, so the note is visible in the design rather
// than only in the panel. Placed on the current page just below the selection's
// bounding box. Touches sketch/dom.
function insertNote(note) {
var sketch = require('sketch/dom');
var document = sketch.getSelectedDocument();
if (!document) return null;
var page = document.selectedPage;
var selected = document.selectedLayers ? document.selectedLayers.layers : [];
var frame = noteFrame(selected);
var text = new sketch.Text({
parent: page,
text: note.heading + '\n\n' + note.body,
frame: frame,
name: note.heading,
});
return text;
}
// noteFrame computes where to drop an inserted note: below the union of the
// selected layers' frames, or a default corner when nothing is selected. Pure
// over plain {frame:{x,y,width,height}} shapes, so it is unit-testable.
function noteFrame(selectedLayers) {
var DEFAULT = { x: 0, y: 0, width: 360, height: 200 };
if (!selectedLayers || !selectedLayers.length) return DEFAULT;
var minX = Infinity;
var maxY = -Infinity;
var maxRight = -Infinity;
for (var i = 0; i < selectedLayers.length; i++) {
var f = selectedLayers[i] && selectedLayers[i].frame;
if (!f) continue;
minX = Math.min(minX, f.x);
maxY = Math.max(maxY, f.y + f.height);
maxRight = Math.max(maxRight, f.x + f.width);
}
if (minX === Infinity) return DEFAULT;
var width = Math.max(360, maxRight - minX);
return { x: minX, y: maxY + 24, width: width, height: 200 };
}
module.exports = {
collectTextLayers: collectTextLayers,
readSelection: readSelection,
applyLayerEdits: applyLayerEdits,
insertNote: insertNote,
noteFrame: noteFrame,
};
+250
View File
@@ -0,0 +1,250 @@
// The assistant WebView. It renders the action bar, runs the selected design
// action through @hanzo/ai (the model call happens HERE — the WebView has
// `fetch`; the plugin CocoaScript runtime does not), and posts results back to
// the plugin over the sketch-module-web-view bridge for write-back.
//
// All prompt shaping, context assembly, and response parsing come from the PURE
// design-core; this file is only glue + DOM.
import {
runAction,
parseLayerEdits,
resultNote,
isPerLayer,
ACTIONS,
DEFAULT_MODEL,
type ActionId,
type Selection,
type LayerEdit,
} from '../design-core.js';
// The bridge injected by sketch-module-web-view: `window.postMessage(name, arg)`
// invokes the matching `webContents.on(name, …)` handler in command.cjs.
declare global {
interface Window {
postMessage(name: string, payload?: unknown): void;
__hanzoSeed?: (payload: SeedPayload) => void;
__hanzoApplied?: (count: number) => void;
}
}
interface SeedPayload {
selection: Selection;
action: ActionId;
key: string;
model: string;
}
const LABELS: Record<ActionId, string> = {
critique: 'Critique',
rewrite: 'Rewrite',
translate: 'Translate',
contentfill: 'Content fill',
ask: 'Ask',
};
// Per-action label + placeholder for the detail field (empty label hides it).
const DETAIL: Record<ActionId, { label: string; placeholder: string } | null> = {
critique: null,
rewrite: { label: 'How to rewrite (optional)', placeholder: 'e.g. shorter, more confident' },
translate: { label: 'Target language', placeholder: 'e.g. Spanish, Japanese' },
contentfill: null,
ask: { label: 'Your question', placeholder: 'What is this selection communicating?' },
};
const $ = <T extends HTMLElement>(id: string) => document.getElementById(id) as T;
const el = {
sel: $<HTMLSpanElement>('sel'),
actionBar: $<HTMLDivElement>('actionBar'),
detailWrap: $<HTMLDivElement>('detailWrap'),
detailLabel: $<HTMLLabelElement>('detailLabel'),
detail: $<HTMLTextAreaElement>('detail'),
run: $<HTMLButtonElement>('run'),
err: $<HTMLDivElement>('err'),
result: $<HTMLDivElement>('result'),
edits: $<HTMLDivElement>('edits'),
editActions: $<HTMLDivElement>('editActions'),
apply: $<HTMLButtonElement>('apply'),
discard: $<HTMLButtonElement>('discard'),
noteActions: $<HTMLDivElement>('noteActions'),
insert: $<HTMLButtonElement>('insert'),
key: $<HTMLInputElement>('key'),
model: $<HTMLInputElement>('model'),
save: $<HTMLButtonElement>('save'),
};
const state = {
selection: { layers: [], totalCount: 0 } as Selection,
action: 'critique' as ActionId,
key: '',
model: '',
pendingEdits: null as LayerEdit[] | null,
pendingNote: null as { heading: string; body: string } | null,
};
// ── Rendering ────────────────────────────────────────────────────────────────
function renderActions() {
el.actionBar.textContent = '';
for (const id of ACTIONS) {
const b = document.createElement('button');
b.textContent = LABELS[id];
b.className = id === state.action ? 'active' : '';
b.onclick = () => {
state.action = id;
renderActions();
renderDetail();
clearOutput();
};
el.actionBar.appendChild(b);
}
}
function renderDetail() {
const d = DETAIL[state.action];
if (!d) {
el.detailWrap.classList.add('hidden');
return;
}
el.detailWrap.classList.remove('hidden');
el.detailLabel.textContent = d.label;
el.detail.placeholder = d.placeholder;
}
function renderSelection() {
const n = state.selection.layers.length;
const total = state.selection.totalCount;
el.sel.textContent = total
? `${n} text layer${n === 1 ? '' : 's'} of ${total} selected`
: 'no selection';
}
function clearOutput() {
state.pendingEdits = null;
state.pendingNote = null;
hide(el.err, el.result, el.edits, el.editActions, el.noteActions);
}
function hide(...nodes: HTMLElement[]) {
for (const n of nodes) n.classList.add('hidden');
}
function show(...nodes: HTMLElement[]) {
for (const n of nodes) n.classList.remove('hidden');
}
function showError(message: string) {
el.err.textContent = message;
show(el.err);
}
// ── Running an action ────────────────────────────────────────────────────────
async function run() {
clearOutput();
if (!state.key) {
showError('Add your Hanzo API key in “Hanzo key & model” above.');
return;
}
el.run.disabled = true;
el.run.textContent = 'Thinking…';
try {
const raw = await runAction({
id: state.action,
detail: el.detail.value,
selection: state.selection,
hanzoToken: state.key,
model: state.model || DEFAULT_MODEL,
});
if (isPerLayer(state.action)) {
const edits = parseLayerEdits(raw, state.selection.layers);
renderEdits(edits);
} else {
renderNote(raw);
}
} catch (e) {
showError(e instanceof Error ? e.message : String(e));
} finally {
el.run.disabled = false;
el.run.textContent = 'Run';
}
}
function renderEdits(edits: LayerEdit[]) {
state.pendingEdits = edits;
el.edits.textContent = '';
const before = new Map(state.selection.layers.map((l) => [l.id, l.text]));
for (const e of edits) {
const card = document.createElement('div');
card.className = 'edit';
const name = document.createElement('div');
name.className = 'name';
name.textContent = e.name || '(unnamed)';
const oldText = document.createElement('div');
oldText.className = 'before';
oldText.textContent = before.get(e.id) ?? '';
const newText = document.createElement('div');
newText.textContent = e.text;
card.append(name, oldText, newText);
el.edits.appendChild(card);
}
show(el.edits, el.editActions);
}
function renderNote(raw: string) {
const note = resultNote(state.action, raw);
state.pendingNote = note;
el.result.textContent = note.body;
show(el.result, el.noteActions);
}
// ── Bridge to the plugin ─────────────────────────────────────────────────────
function applyEdits() {
if (!state.pendingEdits) return;
window.postMessage('applyEdits', { edits: state.pendingEdits });
}
function insertNote() {
if (!state.pendingNote) return;
window.postMessage('insertNote', { note: state.pendingNote });
}
function saveKey() {
state.key = el.key.value.trim();
state.model = el.model.value.trim();
window.postMessage('saveKey', { key: state.key, model: state.model });
}
// The plugin calls this after it seeds/refreshes the WebView with the selection.
window.__hanzoSeed = (payload: SeedPayload) => {
state.selection = payload.selection || { layers: [], totalCount: 0 };
state.action = payload.action || 'critique';
state.key = payload.key || '';
state.model = payload.model || '';
el.key.value = state.key;
el.model.value = state.model;
renderSelection();
renderActions();
renderDetail();
clearOutput();
};
// Plugin confirms how many layers were written; clear the pending edits.
window.__hanzoApplied = () => {
clearOutput();
};
// ── Wiring ───────────────────────────────────────────────────────────────────
el.run.onclick = run;
el.apply.onclick = applyEdits;
el.discard.onclick = clearOutput;
el.insert.onclick = insertNote;
el.save.onclick = saveKey;
renderActions();
renderDetail();
renderSelection();
// Ask the plugin to (re)push the current selection in case we opened before seed.
window.postMessage('refreshSelection');
+126
View File
@@ -0,0 +1,126 @@
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Hanzo AI for Sketch</title>
<style>
:root {
--bg: #ffffff;
--fg: #1d1d1f;
--muted: #6e6e73;
--border: #d2d2d7;
--accent: #000000;
--accent-fg: #ffffff;
--field: #f5f5f7;
--danger: #c1121f;
}
@media (prefers-color-scheme: dark) {
:root {
--bg: #1e1e1e;
--fg: #f5f5f7;
--muted: #a1a1a6;
--border: #3a3a3c;
--accent: #ffffff;
--accent-fg: #000000;
--field: #2c2c2e;
}
}
* { box-sizing: border-box; }
html, body { margin: 0; height: 100%; }
body {
font: 13px/1.45 -apple-system, BlinkMacSystemFont, "SF Pro Text", sans-serif;
color: var(--fg);
background: var(--bg);
display: flex;
flex-direction: column;
}
header {
padding: 12px 14px;
border-bottom: 1px solid var(--border);
display: flex;
align-items: center;
gap: 8px;
}
header .mark { font-weight: 700; letter-spacing: -0.01em; }
header .sel { margin-left: auto; color: var(--muted); font-size: 12px; }
main { flex: 1; overflow-y: auto; padding: 14px; display: flex; flex-direction: column; gap: 12px; }
.actions { display: grid; grid-template-columns: repeat(2, 1fr); gap: 6px; }
.actions button {
padding: 8px; border: 1px solid var(--border); background: var(--field);
color: var(--fg); border-radius: 8px; cursor: pointer; font-size: 12px;
}
.actions button.active { background: var(--accent); color: var(--accent-fg); border-color: var(--accent); }
.actions button:last-child:nth-child(odd) { grid-column: 1 / -1; }
label { display: block; font-size: 12px; color: var(--muted); margin-bottom: 4px; }
input, textarea {
width: 100%; padding: 8px; border: 1px solid var(--border); border-radius: 8px;
background: var(--field); color: var(--fg); font: inherit; resize: vertical;
}
.row { display: flex; gap: 8px; }
.row > * { flex: 1; }
.primary {
padding: 10px; border: none; border-radius: 8px; background: var(--accent);
color: var(--accent-fg); font-weight: 600; cursor: pointer;
}
.primary:disabled { opacity: 0.5; cursor: default; }
.ghost {
padding: 8px 10px; border: 1px solid var(--border); border-radius: 8px;
background: transparent; color: var(--fg); cursor: pointer;
}
.result {
white-space: pre-wrap; border: 1px solid var(--border); border-radius: 8px;
padding: 10px; background: var(--field); min-height: 40px;
}
.edits { display: flex; flex-direction: column; gap: 8px; }
.edit { border: 1px solid var(--border); border-radius: 8px; padding: 8px; }
.edit .name { color: var(--muted); font-size: 11px; margin-bottom: 4px; }
.edit .before { color: var(--muted); text-decoration: line-through; }
.err { color: var(--danger); font-size: 12px; }
.hint { color: var(--muted); font-size: 12px; }
details summary { cursor: pointer; color: var(--muted); font-size: 12px; }
.hidden { display: none !important; }
</style>
<header>
<span class="mark">Hanzo AI</span>
<span class="sel" id="sel">no selection</span>
</header>
<main>
<details id="settings">
<summary>Hanzo key &amp; model</summary>
<div style="margin-top: 8px; display: flex; flex-direction: column; gap: 8px;">
<div>
<label for="key">Hanzo API key (hk-…) or IAM token</label>
<input id="key" type="password" placeholder="hk-…" autocomplete="off" />
</div>
<div>
<label for="model">Model</label>
<input id="model" type="text" placeholder="zen5" autocomplete="off" />
</div>
<button class="ghost" id="save">Save</button>
</div>
</details>
<div class="actions" id="actionBar"></div>
<div id="detailWrap">
<label id="detailLabel" for="detail">Instruction</label>
<textarea id="detail" rows="2"></textarea>
</div>
<button class="primary" id="run">Run</button>
<div class="err hidden" id="err"></div>
<div class="result hidden" id="result"></div>
<div class="edits hidden" id="edits"></div>
<div class="row hidden" id="editActions">
<button class="primary" id="apply">Apply to layers</button>
<button class="ghost" id="discard">Discard</button>
</div>
<div class="row hidden" id="noteActions">
<button class="ghost" id="insert">Add note to canvas</button>
</div>
</main>
<script src="app.js"></script>
+255
View File
@@ -0,0 +1,255 @@
import { describe, it, expect, vi } from 'vitest';
import type { AiClient, ChatCompletion } from '@hanzo/ai';
import {
truncate,
assembleContext,
buildMessages,
actionTask,
isPerLayer,
runAction,
parseLayerEdits,
resultNote,
noteHeading,
extractContent,
ACTIONS,
MAX_CONTEXT_CHARS,
DEFAULT_MODEL,
SYSTEM_PROMPT,
type Selection,
type TextLayer,
type ActionId,
} from '../src/design-core';
// A completion factory matching @hanzo/ai's ChatCompletion shape.
function completion(content: ChatCompletion['choices'][0]['message']['content']): ChatCompletion {
return {
id: 'c1',
object: 'chat.completion',
created: 0,
model: 'zen5',
choices: [{ index: 0, message: { role: 'assistant', content }, finish_reason: 'stop' }],
};
}
// A mock AiClient whose completions.create is a spy returning a fixed response.
function mockClient(res: ChatCompletion): { client: AiClient; create: ReturnType<typeof vi.fn> } {
const create = vi.fn().mockResolvedValue(res);
const client = { chat: { completions: { create } } } as unknown as AiClient;
return { client, create };
}
const sel = (layers: TextLayer[], totalCount = layers.length): Selection => ({ layers, totalCount });
describe('truncate', () => {
it('leaves short text intact', () => {
expect(truncate('hello', 10)).toBe('hello');
});
it('cuts to max and marks the elision with the dropped count', () => {
const out = truncate('abcdefghij', 4);
expect(out.startsWith('abcd')).toBe(true);
expect(out).toContain('truncated 6 chars');
});
});
describe('assembleContext', () => {
it('numbers layers and quotes their names', () => {
const out = assembleContext(
sel([
{ id: 'a', name: 'Title', text: 'Welcome' },
{ id: 'b', name: 'CTA', text: 'Sign up' },
]),
);
expect(out).toBe('[1] "Title": Welcome\n[2] "CTA": Sign up');
});
it('labels unnamed layers', () => {
expect(assembleContext(sel([{ id: 'a', name: '', text: 'hi' }]))).toBe('[1] (unnamed): hi');
});
it('appends a count of non-text layers', () => {
const out = assembleContext(sel([{ id: 'a', name: 'T', text: 'x' }], 3));
expect(out).toContain('+ 2 non-text layers selected');
});
it('uses the singular for exactly one non-text layer', () => {
const out = assembleContext(sel([{ id: 'a', name: 'T', text: 'x' }], 2));
expect(out).toContain('+ 1 non-text layer selected');
});
it('caps the whole assembly at maxChars', () => {
const out = assembleContext(sel([{ id: 'a', name: 'N', text: 'x'.repeat(MAX_CONTEXT_CHARS + 100) }]), 50);
expect(out.length).toBeLessThan(MAX_CONTEXT_CHARS);
expect(out).toContain('truncated');
});
it('is empty for an empty selection', () => {
expect(assembleContext(sel([]))).toBe('');
});
});
describe('buildMessages', () => {
it('fences the selection as data under the system frame', () => {
const msgs = buildMessages('Critique.', 'SELECTION TEXT');
expect(msgs[0]).toEqual({ role: 'system', content: SYSTEM_PROMPT });
expect(msgs[1].role).toBe('user');
const content = msgs[1].content as string;
expect(content).toContain('---- design selection ----');
expect(content).toContain('SELECTION TEXT');
expect(content.startsWith('Critique.')).toBe(true);
});
it('sends only the task when context is empty', () => {
expect(buildMessages('Just ask', ' ')[1].content).toBe('Just ask');
});
});
describe('actionTask', () => {
it('critique is a fixed instruction', () => {
expect(actionTask('critique')).toMatch(/critique this design selection/i);
expect(actionTask('critique')).toMatch(/hierarchy/i);
});
it('rewrite uses the detail, or a sensible default, and asks for a JSON array', () => {
expect(actionTask('rewrite', 'make it punchier')).toContain('make it punchier');
expect(actionTask('rewrite')).toMatch(/clearer, tighter/i);
expect(actionTask('rewrite')).toMatch(/JSON array of strings/);
});
it('translate targets the language (default Spanish) and asks for a JSON array', () => {
expect(actionTask('translate', 'Japanese')).toContain('Japanese');
expect(actionTask('translate')).toContain('Spanish');
expect(actionTask('translate')).toMatch(/JSON array of strings/);
});
it('contentfill is fixed and asks for a JSON array', () => {
expect(actionTask('contentfill')).toMatch(/placeholder copy/i);
expect(actionTask('contentfill')).toMatch(/JSON array of strings/);
});
it('ask returns the question, or a default', () => {
expect(actionTask('ask', 'What is the CTA?')).toBe('What is the CTA?');
expect(actionTask('ask')).toMatch(/what would you improve/i);
});
it('per-layer tasks all share the identical positional-alignment rule', () => {
const rule = 'in the same order';
for (const id of ['rewrite', 'translate', 'contentfill'] as ActionId[]) {
expect(actionTask(id)).toContain(rule);
}
});
});
describe('isPerLayer / ACTIONS', () => {
it('rewrite, translate, contentfill are per-layer; critique, ask are not', () => {
expect(isPerLayer('rewrite')).toBe(true);
expect(isPerLayer('translate')).toBe(true);
expect(isPerLayer('contentfill')).toBe(true);
expect(isPerLayer('critique')).toBe(false);
expect(isPerLayer('ask')).toBe(false);
});
it('ACTIONS is the full catalog in menu order', () => {
expect(ACTIONS).toEqual(['critique', 'rewrite', 'translate', 'contentfill', 'ask']);
});
});
describe('extractContent', () => {
it('reads a plain string content', () => {
expect(extractContent(completion('the answer'))).toBe('the answer');
});
it('joins text parts from an array content, ignoring images', () => {
const res = completion([
{ type: 'text', text: 'a ' },
{ type: 'image_url', image_url: { url: 'x' } },
{ type: 'text', text: 'b' },
]);
expect(extractContent(res)).toBe('a b');
});
it('throws when content is empty', () => {
expect(() => extractContent(completion(''))).toThrow(/no content/i);
});
it('throws when content is whitespace', () => {
expect(() => extractContent(completion(' '))).toThrow(/no content/i);
});
});
describe('runAction (through a mocked AiClient)', () => {
const one = sel([{ id: 'a', name: 'Title', text: 'Welcome' }]);
it('assembles context, forms the task, and returns the model text', async () => {
const { client, create } = mockClient(completion('CRITIQUE'));
const out = await runAction({ id: 'critique', selection: one, hanzoToken: 'hk-x', client });
expect(out).toBe('CRITIQUE');
expect(create).toHaveBeenCalledOnce();
const [params, opts] = create.mock.calls[0];
expect(params.model).toBe(DEFAULT_MODEL);
expect(params.stream).toBe(false);
expect(params.messages[0].content).toBe(SYSTEM_PROMPT);
expect(params.messages[1].content).toContain('Welcome');
expect(opts).toBeUndefined();
});
it('honors an explicit model and passes the detail into the task', async () => {
const { client, create } = mockClient(completion('["Bienvenido"]'));
await runAction({ id: 'translate', detail: 'Spanish', selection: one, hanzoToken: 'hk-x', model: 'zen5-pro', client });
const [params] = create.mock.calls[0];
expect(params.model).toBe('zen5-pro');
expect(params.messages[1].content).toContain('Spanish');
});
it('forwards the abort signal', async () => {
const { client, create } = mockClient(completion('ok'));
const controller = new AbortController();
await runAction({ id: 'ask', selection: one, hanzoToken: 'hk-x', client, signal: controller.signal });
const [, opts] = create.mock.calls[0];
expect(opts).toEqual({ signal: controller.signal });
});
it('propagates a gateway error', async () => {
const create = vi.fn().mockRejectedValue(new Error('HTTP 401: bad key'));
const client = { chat: { completions: { create } } } as unknown as AiClient;
await expect(runAction({ id: 'ask', selection: one, hanzoToken: 'bad', client })).rejects.toThrow(/401/);
});
});
describe('parseLayerEdits', () => {
const layers: TextLayer[] = [
{ id: 'a', name: 'Title', text: 'Welcome' },
{ id: 'b', name: 'CTA', text: 'Sign up' },
];
it('pairs each result positionally with the input layer id/name', () => {
const edits = parseLayerEdits('["Hola", "Regístrate"]', layers);
expect(edits).toEqual([
{ id: 'a', name: 'Title', text: 'Hola' },
{ id: 'b', name: 'CTA', text: 'Regístrate' },
]);
});
it('tolerates a ```json fence and surrounding prose', () => {
const raw = 'Sure!\n```json\n["A", "B"]\n```\nDone.';
expect(parseLayerEdits(raw, layers).map((e) => e.text)).toEqual(['A', 'B']);
});
it('drops extra results beyond the layer count', () => {
expect(parseLayerEdits('["A", "B", "C"]', layers)).toHaveLength(2);
});
it('leaves trailing layers untouched when the model returns too few', () => {
const edits = parseLayerEdits('["Only one"]', layers);
expect(edits).toEqual([{ id: 'a', name: 'Title', text: 'Only one' }]);
});
it('coerces non-string elements to strings', () => {
const edits = parseLayerEdits('[42, null]', layers);
expect(edits.map((e) => e.text)).toEqual(['42', '']);
});
it('throws when no array is present', () => {
expect(() => parseLayerEdits('sorry, I cannot', layers)).toThrow(/no layer-edit array/i);
});
it('throws on malformed JSON inside the brackets', () => {
expect(() => parseLayerEdits('["a", "b"', layers)).toThrow(/no layer-edit array/i);
});
it('throws when the JSON is not an array', () => {
expect(() => parseLayerEdits('{"a":1}', [])).toThrow(/no layer-edit array/i);
});
});
describe('resultNote / noteHeading', () => {
it('labels every action distinctly', () => {
const headings = ACTIONS.map(noteHeading);
expect(new Set(headings).size).toBe(ACTIONS.length);
expect(noteHeading('critique')).toBe('Hanzo AI — Critique');
expect(noteHeading('ask')).toBe('Hanzo AI — Answer');
});
it('builds a trimmed heading+body note', () => {
expect(resultNote('critique', ' the take ')).toEqual({
heading: 'Hanzo AI — Critique',
body: 'the take',
});
});
});
@@ -0,0 +1,73 @@
import { describe, it, expect } from 'vitest';
import { createRequire } from 'node:module';
// sketch-bridge.js is CommonJS (CocoaScript runtime). Its pure exports —
// collectTextLayers and noteFrame — operate on plain layer/frame shapes, so we
// exercise them here without the sketch/dom runtime. readSelection/applyLayerEdits/
// insertNote call require('sketch/dom') and belong to the Sketch host.
const require = createRequire(import.meta.url);
const bridge = require('../src/sketch-bridge.cjs') as {
collectTextLayers: (layers: unknown[]) => Array<{ id: string; name: string; text: string }>;
noteFrame: (
layers: Array<{ frame: { x: number; y: number; width: number; height: number } }>,
) => { x: number; y: number; width: number; height: number };
};
describe('collectTextLayers', () => {
it('flattens Text layers in document order', () => {
const layers = [
{ type: 'Text', id: 'a', name: 'One', text: 'first' },
{ type: 'Text', id: 'b', name: 'Two', text: 'second' },
];
expect(bridge.collectTextLayers(layers)).toEqual([
{ id: 'a', name: 'One', text: 'first' },
{ id: 'b', name: 'Two', text: 'second' },
]);
});
it('recurses into groups/artboards, skipping non-text container layers themselves', () => {
const layers = [
{
type: 'Group',
id: 'g',
layers: [
{ type: 'Text', id: 'a', name: 'Nested', text: 'deep' },
{ type: 'Shape', id: 's', layers: [] },
],
},
{ type: 'Text', id: 'b', name: 'Top', text: 'top' },
];
expect(bridge.collectTextLayers(layers).map((l) => l.id)).toEqual(['a', 'b']);
});
it('defaults missing name/text to empty strings', () => {
const layers = [{ type: 'Text', id: 'a' }];
expect(bridge.collectTextLayers(layers)).toEqual([{ id: 'a', name: '', text: '' }]);
});
it('is empty for no layers', () => {
expect(bridge.collectTextLayers([])).toEqual([]);
expect(bridge.collectTextLayers(undefined as unknown as unknown[])).toEqual([]);
});
});
describe('noteFrame', () => {
it('places the note below the union of selected frames, min-width 360', () => {
const frame = bridge.noteFrame([
{ frame: { x: 100, y: 50, width: 200, height: 40 } },
{ frame: { x: 120, y: 200, width: 80, height: 30 } },
]);
expect(frame.x).toBe(100); // leftmost
expect(frame.y).toBe(200 + 30 + 24); // below the lowest bottom, +24 gap
expect(frame.width).toBe(360); // union width 200 < 360 → clamped
});
it('widens to the union width when it exceeds 360', () => {
const frame = bridge.noteFrame([{ frame: { x: 0, y: 0, width: 500, height: 40 } }]);
expect(frame.width).toBe(500);
});
it('falls back to a default frame for an empty selection', () => {
expect(bridge.noteFrame([])).toEqual({ x: 0, y: 0, width: 360, height: 200 });
});
});
+17
View File
@@ -0,0 +1,17 @@
{
"compilerOptions": {
"target": "ES2020",
"module": "ESNext",
"moduleResolution": "bundler",
"lib": ["ES2020", "DOM", "DOM.Iterable"],
"types": ["node"],
"strict": true,
"noEmit": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"allowJs": true,
"checkJs": false
},
"include": ["src/**/*.ts", "tests/**/*.ts"]
}
+8
View File
@@ -0,0 +1,8 @@
import { defineConfig } from 'vitest/config';
export default defineConfig({
test: {
include: ['tests/**/*.test.ts'],
environment: 'node',
},
});
+1
View File
@@ -18,6 +18,7 @@ packages:
- 'packages/pdf'
- 'packages/salesforce'
- 'packages/shopify'
- 'packages/sketch'
- 'packages/slack'
- 'packages/teams'
- 'packages/tools'