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:
@@ -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 |
@@ -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);
|
||||
});
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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');
|
||||
},
|
||||
};
|
||||
@@ -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() };
|
||||
}
|
||||
@@ -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"]
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
@@ -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');
|
||||
@@ -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 & 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>
|
||||
@@ -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 });
|
||||
});
|
||||
});
|
||||
@@ -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"]
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
import { defineConfig } from 'vitest/config';
|
||||
|
||||
export default defineConfig({
|
||||
test: {
|
||||
include: ['tests/**/*.test.ts'],
|
||||
environment: 'node',
|
||||
},
|
||||
});
|
||||
@@ -18,6 +18,7 @@ packages:
|
||||
- 'packages/pdf'
|
||||
- 'packages/salesforce'
|
||||
- 'packages/shopify'
|
||||
- 'packages/sketch'
|
||||
- 'packages/slack'
|
||||
- 'packages/teams'
|
||||
- 'packages/tools'
|
||||
|
||||
Reference in New Issue
Block a user