Merge remote-tracking branch 'origin/feat/canva'

This commit is contained in:
Hanzo Dev
2026-07-03 21:09:38 -07:00
18 changed files with 1790 additions and 8 deletions
+15
View File
@@ -0,0 +1,15 @@
# Canva Apps SDK configuration, read by `@canva/cli` (`canva apps preview`).
# Copy to `.env` and fill in the App ID from your app in the Canva Developer
# Portal (https://www.canva.com/developers/apps). Never commit `.env`.
# The App ID from the Developer Portal (App details → "App ID").
CANVA_APP_ID=
# The panel dev server / bundle origin the CLI serves. Default matches build.js
# output served at localhost:8080; the CLI can also proxy its own dev server.
CANVA_APP_ORIGIN=http://localhost:8080
# Optional: a backend base if you host a Hanzo-side token exchange. Not required —
# this app takes a user-pasted hk- key and calls api.hanzo.ai directly via
# @hanzo/ai, so no backend is needed for the core flow.
CANVA_BACKEND_HOST=
+111
View File
@@ -0,0 +1,111 @@
# Hanzo AI for Canva
A Canva **Apps SDK** side-panel app that puts Hanzo's models next to your design.
Non-designers, marketers, and SMBs — Canva's core audience — get copywriting help
without leaving the canvas:
- **Generate copy** — headlines, captions, and marketing copy from a short brief.
- **Rewrite selected text** — tighten, restyle, or restate the text you selected, in place.
- **Translate** — the selected text into any language, tone preserved.
- **Content ideas** — a numbered list of angles for a topic; click one to add it.
- **Ask** — a question about the design or its copy.
It reads the current selection and page via `@canva/design`, runs the model gateway
through the **published `@hanzo/ai`** headless client over `api.hanzo.ai`, and adds or
replaces text elements with the result. You paste your own Hanzo `hk-` key; the app
holds no secret and calls no `/api/` path — only `api.hanzo.ai/v1/...`.
## Architecture — one way to do everything
| Module | Responsibility | Depends on |
| --- | --- | --- |
| `src/design-core.ts` | **PURE.** The action catalog, prompt builders, design-context assembly + truncation, and response parsing. No SDK, no DOM. Fully unit-tested. | — |
| `src/hanzo.ts` | Thin wrapper over `@hanzo/ai` — the only place the model gateway is called. | `@hanzo/ai` |
| `src/canva.ts` | Thin adapter over `@canva/design` — read the selection, replace it, insert a new text element. | `@canva/design` |
| `src/config.ts` | Gateway base URL, default model, context budget, `hk-` key guard. | — |
| `src/app.tsx` | The App UI Kit panel — binds the three above to widgets; holds only UI state. | `@canva/app-ui-kit` |
| `src/index.tsx` | Entry — mounts `App` under `AppUiProvider`. | `react-dom`, `@canva/app-ui-kit` |
The design-core mirrors [`@hanzo/figma`](../figma)'s design-core so the two design
apps expose the same five actions with the same context/prompt/parse contracts.
### The read-selection → insert-text flow
1. On mount, `canva.ts` `onSelectionChange` subscribes to `selection.registerOnChange({ scope: 'plaintext' })`. Each change yields a `SelectionSnapshot` (`count`, joined `text`); the panel gates **Rewrite** / **Translate** on `count > 0` and shows a preview of the selected text.
2. You pick an action, add a brief/question/language as needed, and press **Run**.
3. `design-core.buildActionMessages(id, inputs, { selection })` resolves the action's task, fences the observed design as **data** (with an honest truncation note if it was cut), and returns the OpenAI-shaped message list.
4. `hanzo.runChat(messages, { token, model })` calls `@hanzo/ai` `chat.completions.create` (non-streaming) and returns the assistant text.
5. `design-core.parseCopy` / `parseIdeas` strips code fences, wrapping quotes, and list markers into paste-ready text.
6. You press **Replace selection** (Rewrite → `canva.replaceSelectedText`, which reads a fresh draft, overwrites `draft.contents[].text`, and `draft.save()`s) or **Add to design** (everything else → `canva.insertText``addElementAtCursor` with a new `TextElement`). Ideas insert one line per click.
## Develop locally
Prerequisites: Node 18+, pnpm, and a Canva account.
### 1. Create the app in the Canva Developer Portal
1. Go to <https://www.canva.com/developers/apps> and **Create an app**.
2. Under **App details**, copy the **App ID**.
3. Under **Add features → Editing**, enable the app to run in the editor side panel (this app needs the *Design editing* scope: read the selection, add/replace elements).
4. Under **Configure your app**, set the **Development URL** to the CLI/preview origin (default `http://localhost:8080`).
### 2. Configure
```bash
cd packages/canva
cp .env.template .env
# put your App ID into CANVA_APP_ID
```
### 3. Install, test, build
```bash
pnpm install # from the repo root (workspace)
pnpm --filter @hanzo/canva test # vitest — the pure design-core + hanzo shaping
pnpm --filter @hanzo/canva typecheck # tsc --noEmit, strict
pnpm --filter @hanzo/canva build # esbuild → dist/{index.html,app.js,app.css}
```
### 4. Preview in Canva
Serve the bundle and point the Developer Portal's Development URL at it. With the
Canva CLI:
```bash
npx @canva/cli@latest login
pnpm --filter @hanzo/canva watch # rebuilds dist/ on change
npx @canva/cli@latest apps preview # opens the app in the Canva editor, hot-reloading
```
Open any design, launch **Hanzo AI** from the app side panel, paste your `hk-` key,
pick a model, and run an action. Select text on the canvas to enable **Rewrite** and
**Translate**.
### Getting a Hanzo `hk-` key
Sign in at <https://hanzo.id> and mint an API key (prefixed `hk-`). The panel
validates the prefix before use and sends it as the bearer to `api.hanzo.ai`. The key
lives only in the panel's session state — it is never persisted or bundled.
## Submit to the Canva Apps Marketplace
1. Finish and test the app in preview; ensure `pnpm build` is clean and `dist/` loads.
2. Host `dist/` on your production origin (behind `hanzoai/ingress` + the `hanzoai/static` plugin — never nginx/caddy) and set the app's production **App source URL** to it.
3. In the Developer Portal, complete **App listing** (name, icon, description, screenshots) and **Submit for review**.
4. Canva reviews for the requested scopes (Design editing here) and UX. Address feedback and resubmit; on approval the app is published to the Apps Marketplace.
## Tests
`test/design-core.test.ts` covers the pure core: whitespace collapse, context
assembly + budget truncation, message fencing (data vs. task, the truncation note,
the no-context path), the five prompt builders (brief/tweak/language/count/question
embedding and the count clamp), the `id → messages` path, and the response parsers
(fence/quote stripping, idempotence, list-marker stripping). `test/hanzo.test.ts`
asserts the `@hanzo/ai` request shaping against a mock client (model, `stream:false`,
temperature, messages, abort signal, empty-content error, model listing).
`test/config.test.ts` covers the `/v1` endpoint builders and the `hk-` key guard.
```
Test Files 3 passed (3)
Tests 40 passed (40)
```
+71
View File
@@ -0,0 +1,71 @@
// Build @hanzo/canva into dist/: bundle the App UI Kit side-panel app
// (src/index.tsx) as ESM for the browser and stamp index.html with the entry. No
// framework — esbuild + Node stdlib only, mirroring @hanzo/shopify's build. The
// Canva CLI (`canva apps preview`) serves this dist/ (or points at its own dev
// server); either way the app is a static bundle Canva loads in the app iframe.
// Every model call goes to api.hanzo.ai via @hanzo/ai; the app holds no secret —
// the user pastes their own hk- key.
//
// node build.js → production build (dist/index.html, dist/app.js, dist/app.css)
// node build.js --watch → rebuild on change
import esbuild from 'esbuild';
import { rmSync, mkdirSync, readFileSync, writeFileSync, existsSync } from 'node:fs';
import { dirname, join } from 'node:path';
import { fileURLToPath } from 'node:url';
const __dirname = dirname(fileURLToPath(import.meta.url));
const watch = process.argv.includes('--watch');
const src = join(__dirname, 'src');
const dist = join(__dirname, 'dist');
async function build() {
rmSync(dist, { recursive: true, force: true });
mkdirSync(dist, { recursive: true });
// Bundle the panel as ESM for the browser. React + App UI Kit are bundled in
// (the page is served standalone, no import map); the App UI Kit stylesheet
// import emits dist/app.css.
const ctx = await esbuild.context({
entryPoints: { app: join(src, 'index.tsx') },
outdir: dist,
bundle: true,
format: 'esm',
platform: 'browser',
target: ['chrome90', 'edge90', 'firefox90', 'safari15'],
jsx: 'automatic',
loader: { '.css': 'css', '.svg': 'dataurl', '.png': 'dataurl', '.woff': 'dataurl', '.woff2': 'dataurl' },
define: { 'process.env.NODE_ENV': JSON.stringify(watch ? 'development' : 'production') },
sourcemap: true,
minify: !watch,
logLevel: 'info',
});
await ctx.rebuild();
// Stamp index.html with the entry.
const html = readFileSync(join(src, 'index.html'), 'utf8').split('__ENTRY__').join('app.js');
writeFileSync(join(dist, 'index.html'), html);
console.log('Hanzo AI for Canva built -> dist/ (App: dist/index.html)');
if (watch) {
await ctx.watch();
console.log('watching...');
} else {
await ctx.dispose();
}
}
build()
.then(() => {
if (!watch && !existsSync(join(dist, 'app.css'))) {
// The App UI Kit ships its stylesheet via '@canva/app-ui-kit/styles.css'
// (imported in index.tsx); esbuild bundles it to app.css. If a future path
// changes, warn rather than ship an unstyled app.
console.warn('note: dist/app.css not emitted — check the App UI Kit styles import in index.tsx');
}
})
.catch((e) => {
console.error(e);
process.exit(1);
});
+56
View File
@@ -0,0 +1,56 @@
{
"name": "@hanzo/canva",
"version": "0.1.0",
"description": "Hanzo AI for Canva — a side-panel Apps SDK app: generate marketing copy, rewrite the selected text, translate, brainstorm content ideas, and ask about the design. An @canva/app-ui-kit React panel that reads the selection/page via @canva/design and adds/replaces text elements, running the model gateway through the published @hanzo/ai over api.hanzo.ai.",
"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",
"react": "^18.3.1",
"react-dom": "^18.3.1"
},
"peerDependencies": {
"@canva/app-ui-kit": "^5.0.0",
"@canva/asset": "^2.0.0",
"@canva/design": "^2.0.0"
},
"devDependencies": {
"@canva/app-ui-kit": "^5.12.0",
"@canva/asset": "^2.3.0",
"@canva/design": "^2.10.0",
"@types/node": "^20.14.0",
"@types/react": "^18.3.3",
"@types/react-dom": "^18.3.0",
"esbuild": "^0.25.8",
"typescript": "^5.8.3",
"vitest": "^3.2.6"
},
"engines": {
"node": ">=18.0.0"
},
"keywords": [
"hanzo",
"canva",
"apps-sdk",
"copywriting",
"marketing-copy",
"translate",
"ai",
"design",
"app-ui-kit"
],
"author": "Hanzo AI",
"license": "MIT",
"repository": {
"type": "git",
"url": "https://github.com/hanzoai/extension.git",
"directory": "packages/canva"
}
}
+266
View File
@@ -0,0 +1,266 @@
// The Hanzo AI assistant panel — a Canva App UI Kit React component. All the
// logic-heavy work lives in tested, pure modules: the action catalog + prompt
// assembly + response parsing in design-core.ts, the model call in hanzo.ts
// (@hanzo/ai), and the design I/O in canva.ts (@canva/design). This component
// only binds them to App UI Kit widgets and holds the panel's small UI state
// (the pasted key, the picked model, the chosen action, the instruction, the
// output). It never speaks to api.hanzo.ai or the Canva SDK directly.
//
// Flow: pick an action → (for generate/ideas/ask) type a brief, (for rewrite/
// translate) select text on the canvas → Run → design-core builds the request
// from the observed selection/page → hanzo.ts calls the model → design-core
// parses the reply → the panel shows it and inserts it via canva.ts (replace the
// selection in place, or add a new text element).
import { useCallback, useEffect, useMemo, useState } from 'react';
import {
Alert,
Box,
Button,
FormField,
LoadingIndicator,
MultilineInput,
Rows,
Select,
Text,
TextInput,
Title,
} from '@canva/app-ui-kit';
import {
buildActionMessages,
designActionList,
parseCopy,
parseIdeas,
DESIGN_ACTIONS,
type ActionInputs,
type DesignActionId,
type DesignContext,
} from './design-core.js';
import { runChat, listModels } from './hanzo.js';
import { onSelectionChange, replaceSelectedText, insertText, type SelectionSnapshot } from './canva.js';
import { isHanzoKey, DEFAULT_MODEL } from './config.js';
// The catalog the panel renders, derived from design-core so labels/flags never
// drift from what will actually run.
const ACTIONS = designActionList();
type Status = { tone: 'positive' | 'critical' | 'info'; text: string } | null;
export function App(): JSX.Element {
const [key, setKey] = useState('');
const [models, setModels] = useState<string[]>([]);
const [model, setModel] = useState(DEFAULT_MODEL);
const [action, setAction] = useState<DesignActionId>('generate');
const [instruction, setInstruction] = useState('');
const [language, setLanguage] = useState('Spanish');
const [selection, setSelection] = useState<SelectionSnapshot>({ count: 0, text: '' });
const [output, setOutput] = useState('');
const [ideas, setIdeas] = useState<string[]>([]);
const [busy, setBusy] = useState(false);
const [status, setStatus] = useState<Status>(null);
const keyValid = isHanzoKey(key);
const spec = DESIGN_ACTIONS[action];
// Track the live plaintext selection so rewrite/translate know what they act on
// and the panel can gate those actions until something is selected.
useEffect(() => onSelectionChange(setSelection), []);
// Load the model catalog once a valid key is pasted (org-scoped by the bearer).
useEffect(() => {
if (!keyValid) return;
let live = true;
listModels({ token: key })
.then((ids) => {
if (!live) return;
setModels(ids);
if (ids.length && !ids.includes(model)) setModel(ids[0]);
})
.catch(() => live && setModels([]));
return () => {
live = false;
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [keyValid, key]);
// The design context we can observe right now — handed to design-core to fence
// as data. The page text is left to a future read; the selection is the field
// rewrite/translate act on and is always current.
const context = useMemo<DesignContext>(
() => ({ selection: selection.text || undefined }),
[selection.text],
);
const inputs = useMemo<ActionInputs>(
() => ({ instruction, language }),
[instruction, language],
);
// canRun encodes the action's preconditions: a valid key, plus a selection for
// selection-actions and an instruction for instruction-actions.
const missing =
!keyValid
? 'Paste your Hanzo hk- key to begin.'
: spec.needsSelection && selection.count === 0
? 'Select text on your design to run this action.'
: spec.needsInstruction && instruction.trim() === ''
? `Enter ${action === 'ask' ? 'a question' : 'a brief'} to run this action.`
: '';
const run = useCallback(async () => {
if (missing) {
setStatus({ tone: 'info', text: missing });
return;
}
setBusy(true);
setStatus(null);
setOutput('');
setIdeas([]);
try {
const messages = buildActionMessages(action, inputs, context);
const reply = await runChat(messages, { token: key, model });
if (action === 'ideas') {
setIdeas(parseIdeas(reply));
} else {
setOutput(parseCopy(reply));
}
} catch (e) {
setStatus({ tone: 'critical', text: e instanceof Error ? e.message : 'Generation failed' });
} finally {
setBusy(false);
}
}, [missing, action, inputs, context, key, model]);
// Insert the generated copy. Rewrite replaces the selection in place; every
// other action adds a new text element at the cursor.
const place = useCallback(
async (text: string) => {
setBusy(true);
setStatus(null);
try {
if (action === 'rewrite') {
await replaceSelectedText(text);
setStatus({ tone: 'positive', text: 'Replaced the selected text.' });
} else {
await insertText(text, action === 'generate' ? 32 : 18);
setStatus({ tone: 'positive', text: 'Added to your design.' });
}
} catch (e) {
setStatus({ tone: 'critical', text: e instanceof Error ? e.message : 'Could not add to design' });
} finally {
setBusy(false);
}
},
[action],
);
return (
<Box padding="2u">
<Rows spacing="2u">
<Title size="small">Hanzo AI</Title>
<FormField
label="Hanzo API key"
description={keyValid ? 'Key looks valid.' : 'Paste your hk- key from hanzo.id'}
control={(props) => (
<TextInput {...props} placeholder="hk-..." value={key} onChange={setKey} />
)}
/>
{models.length > 0 && (
<FormField
label="Model"
control={() => (
<Select
stretch
value={model}
onChange={setModel}
options={models.map((id) => ({ value: id, label: id }))}
/>
)}
/>
)}
<FormField
label="Action"
control={() => (
<Select
stretch
value={action}
onChange={(v) => setAction(v as DesignActionId)}
options={ACTIONS.map((a) => ({ value: a.id, label: a.label }))}
/>
)}
/>
{action === 'translate' ? (
<FormField
label="Target language"
control={(props) => (
<TextInput {...props} placeholder="Spanish" value={language} onChange={setLanguage} />
)}
/>
) : spec.needsInstruction ? (
<FormField
label={action === 'ask' ? 'Question' : action === 'ideas' ? 'Topic' : 'Brief'}
control={() => (
<MultilineInput
autoGrow
minRows={2}
placeholder={
action === 'ask'
? 'What tone should this poster use?'
: action === 'ideas'
? 'Summer sale for a coffee brand'
: 'A bold headline for our spring launch'
}
value={instruction}
onChange={setInstruction}
/>
)}
/>
) : null}
{spec.needsSelection && (
<Text size="small" tone="tertiary">
{selection.count > 0
? `Selected: ${selection.text.slice(0, 80)}${selection.text.length > 80 ? '…' : ''}`
: 'Nothing selected — select text on your design.'}
</Text>
)}
<Button variant="primary" onClick={() => void run()} disabled={busy} stretch loading={busy}>
{spec.label}
</Button>
{busy && <LoadingIndicator />}
{status && <Alert tone={status.tone}>{status.text}</Alert>}
{output && (
<Rows spacing="1u">
<FormField
label="Result"
control={() => (
<MultilineInput autoGrow minRows={3} value={output} onChange={setOutput} />
)}
/>
<Button variant="primary" onClick={() => void place(output)} disabled={busy} stretch>
{action === 'rewrite' ? 'Replace selection' : 'Add to design'}
</Button>
</Rows>
)}
{ideas.length > 0 && (
<Rows spacing="1u">
<Text size="small" tone="tertiary">Content ideas</Text>
{ideas.map((idea, i) => (
<Button key={i} variant="secondary" onClick={() => void place(idea)} disabled={busy} stretch>
{idea}
</Button>
))}
</Rows>
)}
</Rows>
</Box>
);
}
+107
View File
@@ -0,0 +1,107 @@
// The thin adapter over @canva/design — the only place the Canva Apps SDK is
// touched. It turns Canva's event-driven, draft-based design model into the two
// operations the panel needs: OBSERVE the current selection (its plaintext + a
// mutable handle to overwrite it) and INSERT AI output onto the canvas (add a new
// text element, or replace the selected text in place). All the AI logic lives in
// design-core.ts (pure) and hanzo.ts (@hanzo/ai); this module carries no prompts
// and no model calls — it is pure design I/O.
//
// Canva's contract (@canva/design 2.x):
// - `selection.registerOnChange({ scope: 'plaintext', onChange })` fires with a
// `count` and a `read()` that yields a draft whose `contents[].text` are the
// selected text runs; mutating them and `draft.save()` writes them back.
// - `addElementAtCursor(textElement)` / `addNativeElement(textElement)` add a
// new text element at the user's cursor / onto the current page.
// - `getCurrentPageContext()` yields the page dimensions (used only to size a
// from-scratch insert sensibly).
import {
selection,
addElementAtCursor,
type TextElement,
} from '@canva/design';
// ---- Selection observation ------------------------------------------------
// SelectionSnapshot is what the panel needs to reason about the current
// selection without holding a Canva draft: how many text elements are selected
// and their combined plaintext (for context assembly and for showing the user
// what rewrite/translate will act on). A draft is short-lived and must be
// re-read at write time, so we never stash it — see replaceSelectedText.
export interface SelectionSnapshot {
/** Number of selected plaintext elements. 0 means nothing text is selected. */
count: number;
/** The selected text runs joined with newlines — '' when count is 0. */
text: string;
}
// onSelectionChange subscribes to plaintext selection changes and calls back with
// a SelectionSnapshot each time. It reads the draft only to extract the text (it
// never mutates or saves here) and returns the SDK's unsubscribe function so the
// panel can tear the listener down on unmount. This is the panel's single source
// of "what is selected right now".
export function onSelectionChange(cb: (snap: SelectionSnapshot) => void): () => void {
return selection.registerOnChange({
scope: 'plaintext',
onChange: async (event) => {
if (event.count === 0) {
cb({ count: 0, text: '' });
return;
}
const draft = await event.read();
const text = draft.contents.map((c) => c.text).join('\n');
cb({ count: event.count, text });
},
});
}
// ---- Writing output onto the canvas ---------------------------------------
// replaceSelectedText overwrites the currently-selected plaintext with `text`.
// Canva requires the write to happen inside a fresh draft read (the snapshot the
// panel holds may be stale), so we register a one-shot listener, read the current
// draft, write every selected run — the first run gets the full replacement and
// any further runs are cleared so N selected elements collapse to the one new
// value — save, and unsubscribe. Resolves once saved; rejects if nothing is
// selected when the write is attempted.
export function replaceSelectedText(text: string): Promise<void> {
return new Promise<void>((resolve, reject) => {
let done = false;
const unsubscribe = selection.registerOnChange({
scope: 'plaintext',
onChange: async (event) => {
if (done) return;
done = true;
try {
if (event.count === 0) {
reject(new Error('No text is selected to replace'));
return;
}
const draft = await event.read();
draft.contents.forEach((c, i) => {
c.text = i === 0 ? text : '';
});
await draft.save();
resolve();
} catch (e) {
reject(e instanceof Error ? e : new Error(String(e)));
} finally {
unsubscribe();
}
},
});
});
}
// insertText adds a new text element carrying `text` at the user's cursor. Used
// for generate / ideas / translate-as-new — anything that adds copy rather than
// editing the selection. `fontSize` sizes it (a headline larger than a caption);
// Canva positions it at the cursor.
export function insertText(text: string, fontSize = 24): Promise<void> {
const element: TextElement = {
type: 'text',
children: [text],
fontSize,
};
return addElementAtCursor(element);
}
+50
View File
@@ -0,0 +1,50 @@
// Canva app config — the api.hanzo.ai model gateway (default model + endpoints)
// and the design-context character budget. Endpoints and the bearer choice
// mirror @hanzo/shopify / @hanzo/figma so the productivity suite stays DRY; the
// design-specific pieces (context assembly, the AI actions, response parsing)
// live in design-core.ts, and the two thin adapters — @hanzo/ai in hanzo.ts and
// @canva/design in canva.ts — carry the I/O.
// ---- Hanzo model gateway --------------------------------------------------
// Where the Hanzo model gateway lives. `@hanzo/ai` (createAiClient) defaults
// here too. /v1 only, never an /api/ prefix (api.hanzo.ai IS the api host).
export const HANZO_API_BASE_URL = 'https://api.hanzo.ai';
// Default model. A Zen model (qwen3+). Overridable per-request via the picker;
// the gateway routes it.
export const DEFAULT_MODEL = 'zen5';
// Public IAM origin that mints Hanzo user tokens, and the OAuth client id an
// inbound Hanzo token is audienced to (owner-scoping validation via @hanzo/iam).
// The Canva app itself pastes an `hk-` key; these exist so a Hanzo-hosted
// backend can validate one under the shared <org>-<app> naming scheme.
export const DEFAULT_IAM_SERVER_URL = 'https://hanzo.id';
export const DEFAULT_IAM_CLIENT_ID = 'hanzo-canva';
// chatCompletionsURL / modelsURL — the model gateway endpoints. The client
// (createAiClient) builds these itself; these exist for tests + honest docs.
export function chatCompletionsURL(): string {
return `${HANZO_API_BASE_URL}/v1/chat/completions`;
}
export function modelsURL(): string {
return `${HANZO_API_BASE_URL}/v1/models`;
}
// ---- Design context budget ------------------------------------------------
//
// A Canva design's on-page text (the selection plus the surrounding page text
// we assemble as context) can run long — a poster's body copy, a slide's notes.
// This caps the characters of design context we attach to any one request so it
// fits comfortably in a model window alongside the reply. Honest truncation,
// never silent drop (design-core marks `truncated`).
export const DESIGN_CHAR_BUDGET = 12_000;
// A Hanzo API key is minted by hanzo.id and is always prefixed `hk-`. isHanzoKey
// is the boundary guard: the panel validates a pasted key against this before it
// is ever handed to the client as a bearer, so an obviously-wrong paste (an IAM
// JWT, an OpenAI `sk-` key) is rejected in the UI instead of failing opaquely at
// the gateway. Trim first — users paste with trailing whitespace.
export function isHanzoKey(key: string | undefined | null): key is string {
return typeof key === 'string' && /^hk-[A-Za-z0-9._-]{16,}$/.test(key.trim());
}
+335
View File
@@ -0,0 +1,335 @@
// The design action catalog and everything pure around it: the prompt builders
// for copy / rewrite / translate / ideas / ask, the design-context assembly and
// truncation, the message shaping @hanzo/ai takes, and the response parsing that
// turns a model reply into paste-ready text. NO @canva imports, NO @hanzo/ai
// imports, NO DOM — this module is host-agnostic and fully unit-testable. The
// panel (app.tsx) reads a selection / page via canva.ts, hands the text here to
// build a request, sends it through hanzo.ts, and parses the reply back here
// before inserting it via canva.ts.
//
// It mirrors @hanzo/figma's design-core so the two design apps stay consistent:
// the same five action families, the same context/prompt/parse contracts. There
// is exactly ONE code path from an (action id + inputs) to a message list, and
// ONE path from a raw reply to paste-ready text.
// A message in the OpenAI-compatible schema — the shape @hanzo/ai's
// chat.completions.create takes. A local alias so the pure builders have a
// precise, testable return type independent of the SDK's broader union.
export interface ChatMessage {
role: 'system' | 'user' | 'assistant';
content: string;
}
// SYSTEM_PROMPT grounds every answer in the design context provided. It forbids
// inventing facts about the brand or product (the failure mode that makes a
// marketing assistant dangerous — fake claims, fake prices, fake stats), and
// keeps output ready to drop onto the canvas. Brand-neutral, designer-facing.
export const SYSTEM_PROMPT =
'You are Hanzo AI, a copywriting assistant embedded in Canva. You help create ' +
'marketing copy — headlines, captions, body copy, calls to action — and edit ' +
'the text already on the design. Work from the design context provided below; ' +
'never invent facts, prices, statistics, dates, or claims that are not ' +
'supported by it or by the user\'s instruction. Write in clear, natural, ' +
'on-brand prose sized for a design surface — punchy for headlines, concise for ' +
'captions. Return ONLY the requested text, ready to drop onto the canvas — no ' +
'preamble, no explanation, no surrounding quotes, no markdown fences.';
// ---- Design context assembly ----------------------------------------------
//
// The panel gathers what it can see about the design — the selected text (the
// primary subject of rewrite/translate) and the other text on the current page
// (surrounding voice/context) — and hands it here as a DesignContext. We render
// it into a compact, labeled block the model reads as data (never as
// instructions), and cap the whole block at the budget. Honest truncation.
// What the panel can observe about the design and pass to a prompt builder. All
// fields optional: a blank design has none, a fresh "generate" has only a brief.
export interface DesignContext {
/** The user's selected text — the subject of rewrite/translate. */
selection?: string;
/** Other text on the current page, in reading order — surrounding voice. */
pageText?: string[];
/** The design's title, if the app can read it. */
title?: string;
}
// The result of rendering a DesignContext down to what we attach: the text and
// whether anything was truncated (so the prompt and UI can say so honestly).
export interface RenderedContext {
text: string;
truncated: boolean;
}
// collapseWhitespace normalizes a text fragment for the context block: collapse
// runs of whitespace (including the newlines Canva stores between text lines)
// into single spaces and trim. Pure. The model does not need the layout, and
// raw newlines waste budget and blur the data/instruction boundary.
export function collapseWhitespace(s: string): string {
return s.replace(/\s+/g, ' ').trim();
}
// buildDesignContext renders the observable design into a labeled block, capped
// at `budget` characters. The selection is the field the model most needs (it is
// what rewrite/translate act on), so it is rendered first and the surrounding
// page text — the longest, least essential field — is what truncation trims.
// Pure and total. `truncated` is true whenever the render was cut to fit.
export function buildDesignContext(ctx: DesignContext, budget: number = DESIGN_CHAR_BUDGET): RenderedContext {
const lines: string[] = [];
if (ctx.title) {
const t = collapseWhitespace(ctx.title);
if (t) lines.push(`Design title: ${t}`);
}
if (ctx.selection) {
const sel = collapseWhitespace(ctx.selection);
if (sel) lines.push(`Selected text: ${sel}`);
}
if (ctx.pageText && ctx.pageText.length) {
const page = ctx.pageText.map(collapseWhitespace).filter(Boolean);
if (page.length) lines.push(`Other text on this page: ${page.join(' | ')}`);
}
const full = lines.join('\n');
if (full.length <= budget) return { text: full, truncated: false };
return { text: full.slice(0, budget), truncated: true };
}
// DESIGN_CHAR_BUDGET default mirrors config.ts. Re-declared here as the builder's
// own default so design-core stays importable with zero config dependency (the
// panel passes the config value explicitly when it wants to; tests do not).
const DESIGN_CHAR_BUDGET = 12_000;
// ---- Prompt assembly ------------------------------------------------------
// contextNote is the one honest sentence prepended when the render was cut, so
// the model does not answer as though it saw the whole design.
function contextNote(truncated: boolean): string {
return truncated
? 'Note: the design context below was truncated to fit — answer only from what is shown.'
: '';
}
// buildMessages fences the design context as DATA (never instructions) and rides
// the honest note inside the user turn so it is never lost. The task (the
// resolved action prompt, plus any user instruction) comes last. Pure — the
// whole prompt is asserted in a test. When there is no context (a from-scratch
// generate with only a brief), the fence is omitted so the user turn is just the
// task.
export function buildMessages(task: string, rendered: RenderedContext): ChatMessage[] {
const system: ChatMessage = { role: 'system', content: SYSTEM_PROMPT };
const note = contextNote(rendered.truncated);
const notePrefix = note ? `${note}\n\n` : '';
const fence = rendered.text
? `---- design ----\n${rendered.text}\n---- end design ----\n\n`
: '';
const user: ChatMessage = {
role: 'user',
content: `${notePrefix}${fence}---- task ----\n${task}`,
};
return [system, user];
}
// ---- The action catalog ---------------------------------------------------
//
// An action is (id → label + a prompt builder). The builder takes the action's
// inputs (a brief, a target language, a topic) and returns the task string that
// is layered onto the fenced context by buildMessages. Some actions ignore the
// context (generate, ideas, ask-with-no-selection); some require the selection
// (rewrite, translate). Keeping each action's task in one builder means the
// panel, the picker, and any test resolve prompts one way.
// The inputs a design action may take. All optional; each builder reads only the
// fields it needs and ignores the rest. `instruction` is the user's freeform
// text (the brief for generate, the question for ask, the tweak for rewrite);
// `language` is the target for translate; `count` bounds an ideas list.
export interface ActionInputs {
instruction?: string;
language?: string;
count?: number;
}
// clampCount bounds an ideas request to a sane, promptable range. Pure.
function clampCount(count: number | undefined): number {
if (!count || !Number.isFinite(count)) return 5;
return Math.max(1, Math.min(10, Math.floor(count)));
}
// The five design actions. Each `build` returns the task string. `needsSelection`
// marks the actions that operate on the selected text (the panel disables them
// until something is selected). `needsInstruction` marks the actions that
// require a user brief/question (generate, ask) so the panel can validate before
// sending. Frozen so the catalog is immutable at runtime.
export const DESIGN_ACTIONS = {
generate: {
label: 'Generate copy',
needsSelection: false,
needsInstruction: true,
build: (i: ActionInputs): string => {
const brief = (i.instruction ?? '').trim();
return (
`Write marketing copy for this design based on the brief: "${brief}". ` +
'Return polished, on-brand copy sized for a design surface — a headline, ' +
'a short caption, or a few tight lines as the brief implies. Ground every ' +
'claim in the brief and the design context. Return only the copy.'
);
},
},
rewrite: {
label: 'Rewrite selected text',
needsSelection: true,
needsInstruction: false,
build: (i: ActionInputs): string => {
const tweak = (i.instruction ?? '').trim();
const how = tweak
? `Rewrite it as follows: ${tweak}.`
: 'Rewrite it to be clearer, tighter, and more persuasive, keeping the same meaning and roughly the same length.';
return (
`Rewrite the selected text above. ${how} Do NOT add any fact not present ` +
'in the design context or the instruction. Return only the rewritten text.'
);
},
},
translate: {
label: 'Translate',
needsSelection: true,
needsInstruction: false,
build: (i: ActionInputs): string => {
const lang = (i.language ?? '').trim() || 'Spanish';
return (
`Translate the selected text above into ${lang}. Preserve the tone, ` +
'intent, and any brand names verbatim; localize idioms naturally rather ' +
'than translating word for word. Return only the translated text.'
);
},
},
ideas: {
label: 'Content ideas',
needsSelection: false,
needsInstruction: true,
build: (i: ActionInputs): string => {
const topic = (i.instruction ?? '').trim();
const n = clampCount(i.count);
return (
`Suggest ${n} distinct content ideas for this design about: "${topic}". ` +
'Each idea a single short line (a headline or angle), grounded in the ' +
'topic and the design context. Return a numbered list, nothing else.'
);
},
},
ask: {
label: 'Ask',
needsSelection: false,
needsInstruction: true,
build: (i: ActionInputs): string => {
const q = (i.instruction ?? '').trim();
return (
`Answer this question about the design or its copy: "${q}". Answer only ` +
'from the design context and general copywriting knowledge; if the ' +
'context does not support an answer, say so plainly.'
);
},
},
} as const;
// A design action id from the catalog.
export type DesignActionId = keyof typeof DESIGN_ACTIONS;
// isDesignActionId narrows an arbitrary string to a known id. Boundary guard —
// the panel validates an inbound id here before running it.
export function isDesignActionId(id: string): id is DesignActionId {
return Object.prototype.hasOwnProperty.call(DESIGN_ACTIONS, id);
}
// designActionList is the ordered catalog for building the UI, derived from the
// action map so the panel and the catalog can never drift.
export function designActionList(): Array<{
id: DesignActionId;
label: string;
needsSelection: boolean;
needsInstruction: boolean;
}> {
return (Object.keys(DESIGN_ACTIONS) as DesignActionId[]).map((id) => {
const a = DESIGN_ACTIONS[id];
return { id, label: a.label, needsSelection: a.needsSelection, needsInstruction: a.needsInstruction };
});
}
// actionTask resolves an id + inputs to its task string. Throws on an unknown id
// (a boundary error surfaced to the caller) rather than silently running a
// default. This is the single id→prompt resolution the panel calls.
export function actionTask(id: string, inputs: ActionInputs = {}): string {
if (!isDesignActionId(id)) throw new Error(`Unknown design action: ${id}`);
return DESIGN_ACTIONS[id].build(inputs);
}
// buildActionMessages is the single path from an (action id, inputs, observed
// design) to the message list @hanzo/ai takes. Resolve the task, render the
// context, assemble the messages. Pure — the whole request is asserted in a
// test. The panel calls exactly this, then hands the result to hanzo.ts.
export function buildActionMessages(
id: string,
inputs: ActionInputs,
ctx: DesignContext,
budget: number = DESIGN_CHAR_BUDGET,
): ChatMessage[] {
const task = actionTask(id, inputs);
return buildMessages(task, buildDesignContext(ctx, budget));
}
// ---- Response parsing -----------------------------------------------------
//
// Models sometimes wrap paste-ready text despite the system prompt: a leading
// "Here's your headline:", a Markdown code fence, or matched surrounding quotes.
// parseCopy strips those so the text drops cleanly onto the canvas. Total and
// idempotent — parsing already-clean text returns it unchanged.
// stripFence removes a single wrapping Markdown code fence (```lang ... ```) if
// the whole reply is fenced. It does NOT touch fences in the middle of a reply
// (those are intentional content). Pure.
function stripFence(s: string): string {
const m = s.match(/^```[^\n]*\n([\s\S]*?)\n?```$/);
return m ? m[1] : s;
}
// stripWrappingQuotes removes one matched pair of surrounding quotes (straight or
// curly) when the ENTIRE string is quoted — the model quoting a headline. It
// leaves quotes that are part of the content (an opening quote with no close, an
// internal quote). Pure.
function stripWrappingQuotes(s: string): string {
const pairs: Array<[string, string]> = [
['"', '"'],
["'", "'"],
['“', '”'],
['', ''],
];
for (const [open, close] of pairs) {
if (s.length >= 2 && s.startsWith(open) && s.endsWith(close)) {
const inner = s.slice(1, -1);
// Only strip if the inner text has no unescaped copy of the closing quote,
// i.e. the quotes truly wrap the whole thing rather than being content.
if (!inner.includes(close)) return inner;
}
}
return s;
}
// parseCopy turns a raw model reply into paste-ready text: trim, drop a single
// wrapping code fence, drop matched surrounding quotes, trim again. Idempotent.
// This is the single reply→text path the panel uses before inserting.
export function parseCopy(raw: string): string {
let s = raw.trim();
s = stripFence(s).trim();
s = stripWrappingQuotes(s).trim();
return s;
}
// parseIdeas turns a raw "ideas" reply — a numbered or bulleted list — into a
// clean array of idea lines, dropping the list markers so each idea can be shown
// as its own item and inserted individually. Pure. Blank lines and pure-marker
// lines are dropped; a reply with no list structure yields its non-empty lines.
export function parseIdeas(raw: string): string[] {
return raw
.split('\n')
.map((line) => line.replace(/^\s*(?:\d+[.)]|[-*•])\s+/, '').trim())
.map((line) => stripWrappingQuotes(line).trim())
.filter((line) => line.length > 0);
}
+58
View File
@@ -0,0 +1,58 @@
// The single call path to the model — a THIN wrapper over the PUBLISHED headless
// client `@hanzo/ai` (createAiClient). We do NOT reimplement the transport. This
// module owns only client construction (token + baseURL wiring) and the two
// primitives the panel needs: run a built message list to text, and list models.
// The design-aware layer (context, prompts, parsing) is entirely in
// design-core.ts; the Canva-aware layer (read selection, insert text) is in
// canva.ts. Both are pure of the SDK; this is the only place the SDK is called.
import { createAiClient, type AiClient } from '@hanzo/ai';
import { DEFAULT_MODEL, HANZO_API_BASE_URL } from './config.js';
import type { ChatMessage } from './design-core.js';
export interface AskOptions {
model?: string;
temperature?: number;
/** Hanzo `hk-` key pasted by the user; sent as the bearer. */
token?: string;
baseURL?: string;
/** Injected client (tests). Defaults to a real createAiClient. */
client?: AiClient;
signal?: AbortSignal;
}
// client resolves an AiClient: the injected one (tests) or a real published
// @hanzo/ai client pointed at the gateway with the caller's bearer. One place
// constructs it so the token/baseURL wiring is identical for every call.
function client(opts: AskOptions): AiClient {
return opts.client ?? createAiClient({ token: opts.token, baseUrl: opts.baseURL ?? HANZO_API_BASE_URL });
}
// runChat is the single path from a built message list to the model. Every Canva
// surface (every action) funnels here. Non-streaming: the panel renders the
// final text and inserts it. token may be empty (the gateway serves anonymous/
// limited models), but the panel gates on a valid `hk-` key first.
export async function runChat(messages: ChatMessage[], opts: AskOptions = {}): Promise<string> {
const res = await client(opts).chat.completions.create(
{
model: opts.model ?? DEFAULT_MODEL,
messages,
temperature: opts.temperature,
stream: false,
},
{ signal: opts.signal },
);
const content = res.choices?.[0]?.message?.content;
if (typeof content !== 'string' || content === '') {
throw new Error('Hanzo API returned no content');
}
return content;
}
// listModels returns the model ids the caller may route to, from /v1/models via
// the headless client. Org-scoped by the bearer; an empty token lists public
// models.
export async function listModels(opts: AskOptions = {}): Promise<string[]> {
const models = await client(opts).models.list({ signal: opts.signal });
return models.map((m) => m.id);
}
+12
View File
@@ -0,0 +1,12 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Hanzo AI for Canva</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="__ENTRY__"></script>
</body>
</html>
+19
View File
@@ -0,0 +1,19 @@
// The Canva app entry — mounts the App UI Kit panel into the side-panel root
// under AppUiProvider (which supplies the Canva theme + tokens the App UI Kit
// components read). Canva loads this bundle in the app iframe; the element with
// id `root` is the panel's mount point (see index.html). This file is the ONLY
// DOM entry — all UI is in app.tsx, all logic in the pure/thin modules.
import { createRoot } from 'react-dom/client';
import { AppUiProvider } from '@canva/app-ui-kit';
import '@canva/app-ui-kit/styles.css';
import { App } from './app.js';
const root = document.getElementById('root');
if (!root) throw new Error('Missing #root mount element');
createRoot(root).render(
<AppUiProvider>
<App />
</AppUiProvider>,
);
+36
View File
@@ -0,0 +1,36 @@
import { describe, it, expect } from 'vitest';
import {
HANZO_API_BASE_URL,
DEFAULT_MODEL,
chatCompletionsURL,
modelsURL,
isHanzoKey,
} from '../src/config';
describe('gateway config', () => {
it('points at api.hanzo.ai with /v1 endpoints (never /api/)', () => {
expect(HANZO_API_BASE_URL).toBe('https://api.hanzo.ai');
expect(chatCompletionsURL()).toBe('https://api.hanzo.ai/v1/chat/completions');
expect(modelsURL()).toBe('https://api.hanzo.ai/v1/models');
expect(chatCompletionsURL()).not.toContain('/api/');
});
it('defaults to a Zen model', () => {
expect(DEFAULT_MODEL).toBe('zen5');
});
});
describe('isHanzoKey', () => {
it('accepts a well-formed hk- key (trimming whitespace)', () => {
expect(isHanzoKey('hk-abcdef0123456789ABCDEF')).toBe(true);
expect(isHanzoKey(' hk-abcdef0123456789ABCDEF ')).toBe(true);
});
it('rejects the wrong prefix, too-short, or non-string', () => {
expect(isHanzoKey('sk-abcdef0123456789ABCDEF')).toBe(false);
expect(isHanzoKey('hk-short')).toBe(false);
expect(isHanzoKey('')).toBe(false);
expect(isHanzoKey(undefined)).toBe(false);
expect(isHanzoKey(null)).toBe(false);
});
});
+209
View File
@@ -0,0 +1,209 @@
import { describe, it, expect } from 'vitest';
import {
SYSTEM_PROMPT,
collapseWhitespace,
buildDesignContext,
buildMessages,
buildActionMessages,
DESIGN_ACTIONS,
designActionList,
isDesignActionId,
actionTask,
parseCopy,
parseIdeas,
type ChatMessage,
type DesignContext,
} from '../src/design-core';
describe('collapseWhitespace', () => {
it('collapses runs of whitespace and newlines and trims', () => {
expect(collapseWhitespace(' a\n\n b\t c ')).toBe('a b c');
});
it('is idempotent on clean text', () => {
expect(collapseWhitespace('already clean')).toBe('already clean');
});
});
describe('buildDesignContext', () => {
it('renders title, selection, then page text as labeled data', () => {
const ctx: DesignContext = {
title: 'Spring Launch',
selection: 'Big savings\nthis week',
pageText: ['Shop now', ' Limited time '],
};
const r = buildDesignContext(ctx);
expect(r.truncated).toBe(false);
expect(r.text).toContain('Design title: Spring Launch');
expect(r.text).toContain('Selected text: Big savings this week');
expect(r.text).toContain('Other text on this page: Shop now | Limited time');
// Selection comes before page text (it is what the model most needs).
expect(r.text.indexOf('Selected text')).toBeLessThan(r.text.indexOf('Other text on this page'));
});
it('omits absent fields and yields empty text for an empty design', () => {
expect(buildDesignContext({})).toEqual({ text: '', truncated: false });
const onlySel = buildDesignContext({ selection: 'x' });
expect(onlySel.text).toBe('Selected text: x');
});
it('drops blank page-text entries', () => {
const r = buildDesignContext({ pageText: ['', ' ', 'kept'] });
expect(r.text).toBe('Other text on this page: kept');
});
it('truncates to the budget and flags it', () => {
const r = buildDesignContext({ selection: 'x'.repeat(100) }, 40);
expect(r.truncated).toBe(true);
expect(r.text.length).toBe(40);
});
});
describe('buildMessages', () => {
it('system grounds, design is fenced as data, task last', () => {
const msgs = buildMessages('Do the thing.', { text: 'Selected text: hi', truncated: false });
expect(msgs[0]).toEqual<ChatMessage>({ role: 'system', content: SYSTEM_PROMPT });
expect(msgs[1].role).toBe('user');
expect(msgs[1].content).toContain('---- design ----');
expect(msgs[1].content).toContain('Selected text: hi');
expect(msgs[1].content).toContain('---- end design ----');
expect(msgs[1].content).toContain('---- task ----\nDo the thing.');
});
it('omits the fence when there is no context', () => {
const msgs = buildMessages('Generate.', { text: '', truncated: false });
expect(msgs[1].content).not.toContain('---- design ----');
expect(msgs[1].content).toBe('---- task ----\nGenerate.');
});
it('prepends an honest truncation note when the context was cut', () => {
const msgs = buildMessages('Rewrite.', { text: 'partial', truncated: true });
expect(msgs[1].content).toContain('truncated to fit');
expect(msgs[1].content.indexOf('truncated to fit')).toBeLessThan(msgs[1].content.indexOf('---- design ----'));
});
});
describe('action catalog', () => {
it('exposes the five design actions in order', () => {
expect(Object.keys(DESIGN_ACTIONS)).toEqual(['generate', 'rewrite', 'translate', 'ideas', 'ask']);
});
it('lists are derived from the catalog (labels/flags never drift)', () => {
const list = designActionList();
expect(list.map((a) => a.id)).toEqual(Object.keys(DESIGN_ACTIONS));
expect(list.find((a) => a.id === 'rewrite')?.needsSelection).toBe(true);
expect(list.find((a) => a.id === 'translate')?.needsSelection).toBe(true);
expect(list.find((a) => a.id === 'generate')?.needsSelection).toBe(false);
expect(list.find((a) => a.id === 'generate')?.needsInstruction).toBe(true);
expect(list.find((a) => a.id === 'rewrite')?.needsInstruction).toBe(false);
expect(list[0].label).toBe(DESIGN_ACTIONS.generate.label);
});
it('narrows known ids and rejects unknown', () => {
expect(isDesignActionId('generate')).toBe(true);
expect(isDesignActionId('nope')).toBe(false);
});
});
describe('actionTask (prompt builders)', () => {
it('generate embeds the brief', () => {
const t = actionTask('generate', { instruction: 'Bold spring headline' });
expect(t).toContain('Bold spring headline');
expect(t).toContain('Return only the copy');
});
it('rewrite with no instruction uses the default improve prompt', () => {
const t = actionTask('rewrite', {});
expect(t).toContain('clearer, tighter, and more persuasive');
expect(t).toContain('Do NOT add any fact');
});
it('rewrite with an instruction embeds the tweak', () => {
expect(actionTask('rewrite', { instruction: 'make it playful' })).toContain('Rewrite it as follows: make it playful');
});
it('translate embeds the language and defaults to Spanish', () => {
expect(actionTask('translate', { language: 'French' })).toContain('into French');
expect(actionTask('translate', {})).toContain('into Spanish');
});
it('ideas clamps the count into [1,10] and defaults to 5', () => {
expect(actionTask('ideas', { instruction: 'coffee', count: 3 })).toContain('Suggest 3 distinct content ideas');
expect(actionTask('ideas', { instruction: 'coffee', count: 99 })).toContain('Suggest 10 distinct content ideas');
expect(actionTask('ideas', { instruction: 'coffee', count: 0 })).toContain('Suggest 5 distinct content ideas');
expect(actionTask('ideas', { instruction: 'coffee' })).toContain('Suggest 5 distinct content ideas');
});
it('ask embeds the question', () => {
expect(actionTask('ask', { instruction: 'What tone?' })).toContain('What tone?');
});
it('throws on an unknown id', () => {
expect(() => actionTask('bogus')).toThrow(/Unknown design action/);
});
});
describe('buildActionMessages (id + inputs + design -> messages)', () => {
it('resolves the task and fences the selection', () => {
const msgs = buildActionMessages('rewrite', { instruction: 'shorter' }, { selection: 'Buy now, save big' });
expect(msgs[0].content).toBe(SYSTEM_PROMPT);
expect(msgs[1].content).toContain('Selected text: Buy now, save big');
expect(msgs[1].content).toContain('Rewrite it as follows: shorter');
});
it('a from-scratch generate has no fence, only the task', () => {
const msgs = buildActionMessages('generate', { instruction: 'launch headline' }, {});
expect(msgs[1].content).not.toContain('---- design ----');
expect(msgs[1].content).toContain('launch headline');
});
it('honors the passed budget (truncation note appears)', () => {
const msgs = buildActionMessages('rewrite', {}, { selection: 'x'.repeat(500) }, 50);
expect(msgs[1].content).toContain('truncated to fit');
});
it('rejects an unknown id', () => {
expect(() => buildActionMessages('nope', {}, {})).toThrow(/Unknown design action/);
});
});
describe('parseCopy', () => {
it('returns clean text unchanged (idempotent)', () => {
expect(parseCopy('Spring is here.')).toBe('Spring is here.');
expect(parseCopy(parseCopy('Spring is here.'))).toBe('Spring is here.');
});
it('strips a wrapping markdown code fence', () => {
expect(parseCopy('```\nHeadline copy\n```')).toBe('Headline copy');
expect(parseCopy('```text\nHeadline copy\n```')).toBe('Headline copy');
});
it('strips matched surrounding quotes (straight and curly)', () => {
expect(parseCopy('"Big sale today"')).toBe('Big sale today');
expect(parseCopy('“Big sale today”')).toBe('Big sale today');
});
it('does not strip an internal or unmatched quote', () => {
expect(parseCopy('The "best" coffee')).toBe('The "best" coffee');
expect(parseCopy('"unterminated')).toBe('"unterminated');
});
it('trims surrounding whitespace', () => {
expect(parseCopy(' Hello ')).toBe('Hello');
});
});
describe('parseIdeas', () => {
it('strips numbered and bulleted list markers', () => {
const out = parseIdeas('1. First idea\n2) Second idea\n- Third idea\n* Fourth\n• Fifth');
expect(out).toEqual(['First idea', 'Second idea', 'Third idea', 'Fourth', 'Fifth']);
});
it('drops blank lines and strips per-line wrapping quotes', () => {
expect(parseIdeas('\n1. "Quoted idea"\n\n2. Plain\n')).toEqual(['Quoted idea', 'Plain']);
});
it('falls back to non-empty lines when there is no list structure', () => {
expect(parseIdeas('Just one line')).toEqual(['Just one line']);
expect(parseIdeas('')).toEqual([]);
});
});
+76
View File
@@ -0,0 +1,76 @@
import { describe, it, expect } from 'vitest';
import type { AiClient } from '@hanzo/ai';
import { runChat, listModels } from '../src/hanzo';
import type { ChatMessage } from '../src/design-core';
// A fake AiClient capturing the params passed to chat.completions.create and
// models.list. This is how we assert request shaping without a network.
function fakeClient(reply: string, models: string[] = ['zen5', 'zen5-mini']): {
client: AiClient;
calls: { params: any; options: any }[];
} {
const calls: { params: any; options: any }[] = [];
const client = {
chat: {
completions: {
create: async (params: any, options: any) => {
calls.push({ params, options });
return {
id: 'x',
object: 'chat.completion',
created: 0,
model: params.model,
choices: [{ index: 0, message: { role: 'assistant', content: reply }, finish_reason: 'stop' }],
};
},
},
},
models: {
list: async () => models.map((id) => ({ id, object: 'model' })),
},
} as unknown as AiClient;
return { client, calls };
}
const MESSAGES: ChatMessage[] = [
{ role: 'system', content: 'sys' },
{ role: 'user', content: 'hi' },
];
describe('runChat (request shaping over @hanzo/ai)', () => {
it('sends model + messages, stream:false, temperature, and returns the content', async () => {
const f = fakeClient('Generated copy.');
const out = await runChat(MESSAGES, { client: f.client, model: 'zen5-mini', temperature: 0.4 });
expect(out).toBe('Generated copy.');
expect(f.calls).toHaveLength(1);
expect(f.calls[0].params.model).toBe('zen5-mini');
expect(f.calls[0].params.stream).toBe(false);
expect(f.calls[0].params.temperature).toBe(0.4);
expect(f.calls[0].params.messages).toEqual(MESSAGES);
});
it('defaults the model to zen5 when none is given', async () => {
const f = fakeClient('x');
await runChat(MESSAGES, { client: f.client });
expect(f.calls[0].params.model).toBe('zen5');
});
it('throws when the model returns empty content', async () => {
const f = fakeClient('');
await expect(runChat(MESSAGES, { client: f.client })).rejects.toThrow(/no content/);
});
it('forwards the abort signal in options', async () => {
const f = fakeClient('x');
const controller = new AbortController();
await runChat(MESSAGES, { client: f.client, signal: controller.signal });
expect(f.calls[0].options.signal).toBe(controller.signal);
});
});
describe('listModels', () => {
it('returns the model ids from the client', async () => {
const f = fakeClient('x', ['zen5', 'zen5-mini', 'zen5-coder']);
expect(await listModels({ client: f.client })).toEqual(['zen5', 'zen5-mini', 'zen5-coder']);
});
});
+17
View File
@@ -0,0 +1,17 @@
{
"compilerOptions": {
"target": "ES2020",
"module": "ESNext",
"moduleResolution": "bundler",
"lib": ["ES2020", "DOM", "DOM.Iterable"],
"types": ["node"],
"jsx": "react-jsx",
"strict": true,
"noEmit": true,
"esModuleInterop": true,
"skipLibCheck": true,
"resolveJsonModule": true,
"forceConsistentCasingInFileNames": true
},
"include": ["src/**/*.ts", "src/**/*.tsx", "test/**/*.ts"]
}
+8
View File
@@ -0,0 +1,8 @@
import { defineConfig } from 'vitest/config';
export default defineConfig({
test: {
include: ['test/**/*.test.ts'],
environment: 'node',
},
});
+343 -8
View File
@@ -258,6 +258,49 @@ importers:
specifier: ^3.2.6
version: 3.2.6(@types/node@22.7.5)(jsdom@26.1.0)(sass@1.60.0)(terser@5.43.1)
packages/canva:
dependencies:
'@hanzo/ai':
specifier: ^0.2.0
version: 0.2.0
'@hanzo/iam':
specifier: ^0.13.2
version: 0.13.2(react@18.3.1)
react:
specifier: ^18.3.1
version: 18.3.1
react-dom:
specifier: ^18.3.1
version: 18.3.1(react@18.3.1)
devDependencies:
'@canva/app-ui-kit':
specifier: ^5.12.0
version: 5.12.0(@types/react@18.3.23)(mobx@6.16.1)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
'@canva/asset':
specifier: ^2.3.0
version: 2.3.0(@canva/error@2.2.1)
'@canva/design':
specifier: ^2.10.0
version: 2.10.0(@canva/error@2.2.1)
'@types/node':
specifier: ^20.14.0
version: 20.19.9
'@types/react':
specifier: ^18.3.3
version: 18.3.23
'@types/react-dom':
specifier: ^18.3.0
version: 18.3.7(@types/react@18.3.23)
esbuild:
specifier: ^0.25.8
version: 0.25.8
typescript:
specifier: ^5.8.3
version: 5.8.3
vitest:
specifier: ^3.2.6
version: 3.2.6(@types/node@20.19.9)(jsdom@26.1.0)(sass@1.60.0)(terser@5.43.1)
packages/cards:
devDependencies:
'@types/node':
@@ -292,7 +335,7 @@ importers:
dependencies:
'@hanzo/ai':
specifier: 0.1.1
version: 0.1.1(@types/react-dom@18.3.7(@types/react@18.3.23))(@types/react@18.3.23)(@zxing/library@0.22.0)(plotly.js@3.7.0(mapbox-gl@1.13.3))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.8.3)
version: 0.1.1(@types/react-dom@18.3.7(@types/react@18.3.23))(@types/react@18.3.23)(@zxing/library@0.22.0)(plotly.js@3.7.0(mapbox-gl@1.13.3))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.8.3)(use-sync-external-store@1.6.0(react@18.3.1))
'@hanzo/iam':
specifier: 0.13.2
version: 0.13.2(react@18.3.1)
@@ -440,7 +483,7 @@ importers:
version: 1.2.0
'@hanzo/ai':
specifier: 0.1.1
version: 0.1.1(@types/react-dom@18.3.7(@types/react@18.3.23))(@types/react@18.3.23)(@zxing/library@0.22.0)(plotly.js@3.7.0(mapbox-gl@1.13.3))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.8.3)
version: 0.1.1(@types/react-dom@18.3.7(@types/react@18.3.23))(@types/react@18.3.23)(@zxing/library@0.22.0)(plotly.js@3.7.0(mapbox-gl@1.13.3))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.8.3)(use-sync-external-store@1.6.0(react@18.3.1))
'@hanzo/iam':
specifier: 0.13.2
version: 0.13.2(react@18.3.1)
@@ -465,7 +508,7 @@ importers:
dependencies:
'@hanzo/ai':
specifier: ^0.1.1
version: 0.1.1(@types/react-dom@18.3.7(@types/react@18.3.23))(@types/react@18.3.23)(@zxing/library@0.22.0)(plotly.js@3.7.0(mapbox-gl@1.13.3))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.8.3)
version: 0.1.1(@types/react-dom@18.3.7(@types/react@18.3.23))(@types/react@18.3.23)(@zxing/library@0.22.0)(plotly.js@3.7.0(mapbox-gl@1.13.3))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.8.3)(use-sync-external-store@1.6.0(react@18.3.1))
'@hanzo/iam':
specifier: ^0.13.2
version: 0.13.2(react@18.3.1)
@@ -590,7 +633,7 @@ importers:
dependencies:
'@hanzo/ai':
specifier: ^0.1.1
version: 0.1.1(@types/react-dom@18.3.7(@types/react@18.3.23))(@types/react@18.3.23)(@zxing/library@0.22.0)(plotly.js@3.7.0(mapbox-gl@1.13.3))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.8.3)
version: 0.1.1(@types/react-dom@18.3.7(@types/react@18.3.23))(@types/react@18.3.23)(@zxing/library@0.22.0)(plotly.js@3.7.0(mapbox-gl@1.13.3))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.8.3)(use-sync-external-store@1.6.0(react@18.3.1))
'@hanzo/cards':
specifier: workspace:*
version: link:../cards
@@ -615,7 +658,7 @@ importers:
dependencies:
'@hanzo/ai':
specifier: ^0.1.1
version: 0.1.1(@types/react-dom@18.3.7(@types/react@18.3.23))(@types/react@18.3.23)(@zxing/library@0.22.0)(plotly.js@3.7.0(mapbox-gl@1.13.3))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.8.3)
version: 0.1.1(@types/react-dom@18.3.7(@types/react@18.3.23))(@types/react@18.3.23)(@zxing/library@0.22.0)(plotly.js@3.7.0(mapbox-gl@1.13.3))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.8.3)(use-sync-external-store@1.6.0(react@18.3.1))
'@hanzo/cards':
specifier: workspace:*
version: link:../cards
@@ -1617,6 +1660,26 @@ packages:
'@borewit/text-codec@0.2.2':
resolution: {integrity: sha512-DDaRehssg1aNrH4+2hnj1B7vnUGEjU6OIlyRdkMd0aUdIUvKXrJfXsy8LVtXAy7DRvYVluWbMspsRhz2lcW0mQ==}
'@canva/app-ui-kit@5.12.0':
resolution: {integrity: sha512-fj0GydEqOrXLYBEBwv95MYunPFqoiPisZHFnD9XLhdvGqK0BAKOlVVbx/Wb6z9vZbQLcjBQcGwYysYHBGWpGmg==}
peerDependencies:
mobx: ^6.15.1
react: ^19.2.3
react-dom: ^19.2.3
'@canva/asset@2.3.0':
resolution: {integrity: sha512-dZHywIsZEoJin6fJVd2gFwSFp020otrA3gd6h+XAuCoMam7CjyNfE6FV6uYMSv4I/j56wLj0QHwndVMLJCpnxg==}
peerDependencies:
'@canva/error': ^2.0.0
'@canva/design@2.10.0':
resolution: {integrity: sha512-LwHA0U0GdS/wD3zn7Ead6iroYWvIVJL3uO2KwXx2J7ULw76vaxJBNZI+uZptr7wwzG+KiGXnrNeQ5fEiAHHriA==}
peerDependencies:
'@canva/error': ^2.0.0
'@canva/error@2.2.1':
resolution: {integrity: sha512-b5GQMggNaLqtQdN4ENXx2PifFNTO9Ulw8TEwbMZjbPmLS7cAQPfQvXqb0SwPcQ2LQhCIanErrcpOPxQ4s60QNA==}
'@choojs/findup@0.2.1':
resolution: {integrity: sha512-YstAqNb0MCN8PjdLCDfRsBcGVRN41f3vgLvaI0IrIcBp4AqILRSS0DeWNGkicC+f/zRIPJLc+9RURVSepwvfBw==}
hasBin: true
@@ -2211,9 +2274,30 @@ packages:
react: '>=16.8.0'
react-dom: '>=16.8.0'
'@floating-ui/react@0.26.28':
resolution: {integrity: sha512-yORQuuAtVpiRjpMhdc0wJj06b9JFjrYF4qp96j++v2NBpbi6SEGF7donUJ3TMieerQ6qVkAv1tgr7L4r5roTqw==}
peerDependencies:
react: '>=16.8.0'
react-dom: '>=16.8.0'
'@floating-ui/utils@0.2.11':
resolution: {integrity: sha512-RiB/yIh78pcIxl6lLMG0CgBXAZ2Y0eVHqMPYugu+9U0AeT6YBeiJpf7lbdJNIugFP5SIjwNRgo4DhR1Qxi26Gg==}
'@formatjs/ecma402-abstract@2.3.6':
resolution: {integrity: sha512-HJnTFeRM2kVFVr5gr5kH1XP6K0JcJtE7Lzvtr3FS/so5f1kpsqqqxy5JF+FRaO6H2qmcMfAUIox7AJteieRtVw==}
'@formatjs/fast-memoize@2.2.7':
resolution: {integrity: sha512-Yabmi9nSvyOMrlSeGGWDiH7rf3a7sIwplbvo/dlz9WCIjzIQAfy1RMf4S0X3yG724n5Ghu2GmEl5NJIV6O9sZQ==}
'@formatjs/icu-messageformat-parser@2.11.4':
resolution: {integrity: sha512-7kR78cRrPNB4fjGFZg3Rmj5aah8rQj9KPzuLsmcSn4ipLXQvC04keycTI1F7kJYDwIXtT2+7IDEto842CfZBtw==}
'@formatjs/icu-skeleton-parser@1.8.16':
resolution: {integrity: sha512-H13E9Xl+PxBd8D5/6TVUluSpxGNvFSlN/b3coUp0e0JpuWXXnQDiavIpY3NnvSp4xhEMoXyyBvVfdFX8jglOHQ==}
'@formatjs/intl-localematcher@0.6.2':
resolution: {integrity: sha512-XOMO2Hupl0wdd172Y06h6kLpBz6Dv+J4okPLl4LPtzbr8f66WbIoy4ev98EBuZ6ZK4h5ydTN6XneT4QVpD7cdA==}
'@google/clasp@2.5.0':
resolution: {integrity: sha512-HkZnUP5UibGEYXpk89HR24pFctb9VBlrpjCrU6sjepkQOtpr/Y9rrSP3N7EVrqUDg4/JQmmPQ8Z2ybN4XtdOxg==}
engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0}
@@ -2810,6 +2894,7 @@ packages:
'@lancedb/lancedb@0.21.0':
resolution: {integrity: sha512-3c65DfAsTzGb3/Y2WtpJeqPFb2BgoTsYweg3gj9zP6t8CCaKQSeVWEGE8Nowtdj5Jkj9/nmHUoKPv4jCsds2yQ==}
engines: {node: '>= 18'}
cpu: [x64, arm64]
os: [darwin, linux, win32]
peerDependencies:
apache-arrow: '>=15.0.0 <=18.1.0'
@@ -2975,16 +3060,19 @@ packages:
'@nut-tree-fork/libnut-darwin@2.7.5':
resolution: {integrity: sha512-LbqtPtMPTJUcg4XoPP2jsU1wc8flBcGyKTerKsIfK9cD7nBHROnO0QksbrsbSWEpLym8T8fRtuU7XEY83l6Z2Q==}
engines: {node: '>=10.15.3'}
cpu: [x64, arm64]
os: [darwin, linux, win32]
'@nut-tree-fork/libnut-linux@2.7.5':
resolution: {integrity: sha512-uxaXEcRKnFObAljsoR6tLOBUU1dJ2sctloG6gFgCBGN7+k6Jdv6jZfOuNjd/fpdq2C5WPMm0rtn9EE7h5J3Jcg==}
engines: {node: '>=10.15.3'}
cpu: [x64, arm64]
os: [darwin, linux, win32]
'@nut-tree-fork/libnut-win32@2.7.5':
resolution: {integrity: sha512-yqC87zvmFcDPwFrRU40DYhN0xmEVM3aSkOuyF0IX+y1x+HWSu/i0PNklATpPBhGid3QVb/TOHuVoaraMrUFCNw==}
engines: {node: '>=10.15.3'}
cpu: [x64, arm64]
os: [darwin, linux, win32]
'@nut-tree-fork/libnut@4.2.6':
@@ -2998,6 +3086,7 @@ packages:
'@nut-tree-fork/nut-js@4.2.6':
resolution: {integrity: sha512-aI/WCX7gE1HFGPH3EZP/UWqpNMM1NMoM/EkXqp7pKMgXFCi8e5+o5p+jd/QOYpmALv9bQg7+s69nI7FONbMqDg==}
engines: {node: '>=16'}
cpu: [x64, arm64]
os: [linux, darwin, win32]
'@nut-tree-fork/provider-interfaces@4.2.6':
@@ -3794,6 +3883,9 @@ packages:
'@standard-schema/spec@1.1.0':
resolution: {integrity: sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==}
'@stylexjs/stylex@0.18.3':
resolution: {integrity: sha512-15gDzAJAorOE0yzxaWLNxldW2aqdmCiLG5QcPD8nmVCVqvrWp0Asgv45zk7LtN86WJb/4Ym9eQ6qT5MJW59tWQ==}
'@supabase/auth-js@2.70.0':
resolution: {integrity: sha512-BaAK/tOAZFJtzF1sE3gJ2FwTjLf4ky3PSvcvLGEgEmO4BSBkwWKu8l67rLLIBZPDnCyV7Owk2uPyKHa0kj5QGg==}
@@ -3982,6 +4074,11 @@ packages:
'@types/hast@3.0.4':
resolution: {integrity: sha512-WPs+bbQw5aCj+x6laNGWLH3wviHtoCv/P3+otBhbOhJgG8qtpdAMlTCxLtsTWA7LH1Oh/bFCHsBn0TPS5m30EQ==}
'@types/hoist-non-react-statics@3.3.7':
resolution: {integrity: sha512-PQTyIulDkIDro8P+IHbKCsw7U2xxBYflVzW/FgWdCAePD9xGSidgA76/GeJ6lBKoblyhf9pBY763gbrN+1dI8g==}
peerDependencies:
'@types/react': '*'
'@types/http-cache-semantics@4.2.0':
resolution: {integrity: sha512-L3LgimLHXtGkWikKnsPg0/VFx9OGZaC+eN1u4r+OB1XRqH3meBIAVC2zr1WdMH+RHmnRkqliQAOHNJ/E0j/e0Q==}
@@ -4636,6 +4733,9 @@ packages:
as-typed@1.3.2:
resolution: {integrity: sha512-94ezeKlKB97OJUyMaU7dQUAB+Cmjlgr4T9/cxCoKaLM4F2HAmuIHm3Q5ClGCsX5PvRwCQehCzAa/6foRFXRbqA==}
asap@2.0.6:
resolution: {integrity: sha512-BSHWgDSAiKs50o2Re8ppvp3seVHXSRM44cdSsT9FfNEUUZLOGWVCsiWaRPWM1Znn+mqZ1OfVZ3z3DWEzSp7hRA==}
asn1@0.2.6:
resolution: {integrity: sha512-ix/FxPn0MDjeyJ7i/yoHGFt/EX6LyNbxSEhPPXODPL+KB0VPk86UYfL0lMdy+KCnv+fmvIzySwaK5COwqVbWTQ==}
@@ -5080,6 +5180,9 @@ packages:
clamp@1.0.1:
resolution: {integrity: sha512-kgMuFyE78OC6Dyu3Dy7vcx4uy97EIbVxJB/B0eJ3bUNAkwdNcxYzgKltnyADiYwsR7SEqkkUPsEUT//OVS6XMA==}
classnames@2.5.1:
resolution: {integrity: sha512-saHYOzhIQs6wy2sVxTM6bUDsQO4F50V9RQ22qBpEdCW+I+/Wmke2HOl6lS6dTpdxVhb88/I6+Hs+438c3lfUow==}
clean-stack@2.2.0:
resolution: {integrity: sha512-4diC9HaTE+KRAMWhDhrGOECgWZxoevMc5TlkObMqNSsVU62PYzXZ/SMTjzyGAFF1YusgxGcSWTEXBhp0CPwQ1A==}
engines: {node: '>=6'}
@@ -5396,6 +5499,9 @@ packages:
css-global-keywords@1.0.1:
resolution: {integrity: sha512-X1xgQhkZ9n94WDwntqst5D/FKkmiU0GlJSFZSV3kLvyJ1WC5VeyoXDOuleUD+SIuH9C7W05is++0Woh0CGfKjQ==}
css-mediaquery@0.1.2:
resolution: {integrity: sha512-COtn4EROW5dBGlE/4PiKnh6rZpAPxDeFLaEEwt4i10jpDMFt2EhQGS79QmmrO+iKCHv0PU/HrOWEhijFd1x99Q==}
css-select@5.2.2:
resolution: {integrity: sha512-TizTzUddG/xYLA3NXodFM0fSbNizXjOKhqiQQwvhlspadZokn1KDy0NZFS0wuEubIYAV5/c1/lAr0TaaFXEXzw==}
@@ -5716,6 +5822,9 @@ packages:
resolution: {integrity: sha512-WkrWp9GR4KXfKGYzOLmTuGVi1UWFfws377n9cc55/tb6DuqyF6pcQ5AbiHEshaDpY9v6oaSr2XCDidGmMwdzIA==}
engines: {node: '>=8'}
dnd-core@7.7.0:
resolution: {integrity: sha512-+YqwflWEY1MEAEl2QiEiRaglYkCwIZryyQwximQGuTOm/ns7fS6Lg/i7OCkrtjM10D5FhArf/VUHIL4ZaRBK0g==}
doctrine@3.0.0:
resolution: {integrity: sha512-yS+Q5i3hBf7GBkd4KG8a7eBNNWNGLTaEwwYWUijIYM7zrlYDM0BFXHjjPWlWZ1Rg7UaddZeIDmi9jF3HmqiQ2w==}
engines: {node: '>=6.0.0'}
@@ -6674,6 +6783,9 @@ packages:
highlightjs-vue@1.0.0:
resolution: {integrity: sha512-PDEfEF102G23vHmPhLyPboFCD+BkMGu+GuJe2d9/eH4FsCwvgBpnc9n0pGE+ffKdph38s6foEZiEjdgHdzp+IA==}
hoist-non-react-statics@3.3.2:
resolution: {integrity: sha512-/gGivxi8JPKWNm/W0jSmzcMPpfpPLc3dY/6GxhX2hQ9iGj3aDfklV4ET7NjKpSinLpJ5vafa9iiGIEZg10SfBw==}
hono@4.12.27:
resolution: {integrity: sha512-1yrb/+w6HWQJrUCLkJ2IF5jNIPvvFkblV5RNOYl6bV+OA6p9GLcMpHFFGTosSvHvcAUibuUukRqhlYI4z32C7Q==}
engines: {node: '>=16.9.0'}
@@ -6856,6 +6968,12 @@ packages:
resolution: {integrity: sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg==}
engines: {node: '>=12'}
intl-messageformat@10.7.18:
resolution: {integrity: sha512-m3Ofv/X/tV8Y3tHXLohcuVuhWKo7BBq62cqY15etqmLxg2DZ34AGGgQDeR+SCta2+zICb1NX83af0GJmbQ1++g==}
invariant@2.2.4:
resolution: {integrity: sha512-phJfQVBuaJM5raOpJjSfkiD6BpbCE4Ns//LaXl6wGYtUBY83nWS6Rf9tXm2e8VaK60JEjYldbPif/A2B1C2gNA==}
ip-address@10.1.1:
resolution: {integrity: sha512-1FMu8/N15Ck1BL551Jf42NYIoin2unWjLQ2Fze/DXryJRl5twqtwNHlO39qERGbIOcKYWHdgRryhOC+NG4eaLw==}
engines: {node: '>= 12'}
@@ -7857,6 +7975,27 @@ packages:
mlly@1.7.4:
resolution: {integrity: sha512-qmdSIPC4bDJXgZTCR7XosJiNKySV7O215tsPtDN9iEO/7q/76b/ijtgRu/+epFXSJhijtTCCGp3DWS549P3xKw==}
mobx-react-lite@4.1.1:
resolution: {integrity: sha512-iUxiMpsvNraCKXU+yPotsOncNNmyeS2B5DKL+TL6Tar/xm+wwNJAubJmtRSeAoYawdZqwv8Z/+5nPRHeQxTiXg==}
peerDependencies:
mobx: ^6.9.0
react: ^16.8.0 || ^17 || ^18 || ^19
react-dom: '*'
react-native: '*'
peerDependenciesMeta:
react-dom:
optional: true
react-native:
optional: true
mobx-utils@6.1.1:
resolution: {integrity: sha512-ZR4tOKucWAHOdMjqElRl2BEvrzK7duuDdKmsbEbt2kzgVpuLuoYLiDCjc3QwWQl8CmOlxPgaZQpZ7emwNqPkIg==}
peerDependencies:
mobx: ^6.0.0
mobx@6.16.1:
resolution: {integrity: sha512-syNcDdX3KT+Jq3je6eGjBhuc24Z68td2VG0zNFqRswaE433D9SNH5VRy/xrGbJsUixfppLLccXhAW9JSf6n+SQ==}
mocha@11.7.1:
resolution: {integrity: sha512-5EK+Cty6KheMS/YLPPMJC64g5V61gIR25KsRItHw6x4hEKT6Njp1n9LOlH4gpevuwMVS66SXaBBpg+RWZkza4A==}
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
@@ -8074,6 +8213,9 @@ packages:
resolution: {integrity: sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==}
engines: {node: '>=0.10.0'}
normalize-scroll-left@0.2.1:
resolution: {integrity: sha512-YanMJCtsykxVQuWiwQR531bbyPtMt/jpUKy2XcVVe/oHiDgYme0NmuEZfceLeZhFbrQtO/GS/9KvWbSfDGRblA==}
normalize-svg-path@0.1.0:
resolution: {integrity: sha512-1/kmYej2iedi5+ROxkRESL/pI02pkg0OBnaR4hJkSIX6+ORzepwbuUXfrdZaPjysTsJInj0Rj5NuX027+dMBvA==}
@@ -8688,6 +8830,15 @@ packages:
resolution: {integrity: sha512-y3bGgqKj3QBdxLbLkomlohkvsA8gdAiUQlSBJnBhfn+BPxg4bc62d8TcBW15wavDfgexCgccckhcZvywyQYPOw==}
hasBin: true
react-dnd-html5-backend@7.7.0:
resolution: {integrity: sha512-JgKmWOxqorFyfGPeWV+SAPhVGFe6+LrOR24jETE9rrYZU5hCppzwK9ujjS508kWibeDvbfEgi9j5qC2wB1/MoQ==}
react-dnd@7.7.0:
resolution: {integrity: sha512-anpJDKMgdXE6kXvtBFmIAe1fuaexpVy7meUyNjiTfCnjQ1mRvnttGTVvcW9fMKijsUQYadiyvzd3BRxteFuVXg==}
peerDependencies:
react: '>= 16.8'
react-dom: '>= 16.8'
react-dom@18.3.1:
resolution: {integrity: sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==}
peerDependencies:
@@ -8902,6 +9053,9 @@ packages:
redeyed@2.1.1:
resolution: {integrity: sha512-FNpGGo1DycYAdnrKFxCMmKYgo/mILAqtRYbkdQD8Ep/Hk2PQ5+aEAEx+IU713RTDmuBaH0c8P5ZozurNu5ObRQ==}
redux@4.2.1:
resolution: {integrity: sha512-LAUYz4lc+Do8/g7aeRa8JkyDErK6ekstQaqWQrNRW//MY1TvCEpMtpTWvlQ+FPbWCx+Xixu/6SHt5N0HR+SB4w==}
reflect-metadata@0.2.2:
resolution: {integrity: sha512-urBwgfrvVP/eAyXx4hluJivBKzuEbSQs9rKWCrCkbSxNv8mxPcUZKeuoF3Uy4mJl3Lwprp6yy5/39VWigZ4K6Q==}
@@ -8966,6 +9120,9 @@ packages:
requires-port@1.0.0:
resolution: {integrity: sha512-KigOCHcocU3XODJxsu8i/j8T9tzT4adHiecwORRQ0ZZFcp7ahwXuRU1m+yuO90C5ZUyGeGfocHDI14M3L3yDAQ==}
resize-observer-polyfill@1.5.1:
resolution: {integrity: sha512-LwZrotdHOo12nQuZlHEmtuXdqGoOD0OhaxopaNFxWzInpEgaLWoVuAMbTzixuosCx2nEG58ngzW3vxdWoxIgdg==}
resolve-alpn@1.2.1:
resolution: {integrity: sha512-0a1F4l73/ZFZOakJnQ3FvkJ2+gSTQWz/r2KE5OdDY0TxPm5h4GkqkWWfM47T7HsbnOtcJVEF4epCVy6u7Q3K+g==}
@@ -9136,6 +9293,9 @@ packages:
scheduler@0.23.2:
resolution: {integrity: sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ==}
seedrandom@3.0.5:
resolution: {integrity: sha512-8OwmbklUNzwezjGInmZ+2clQmExQPvomqjL7LFqOYqtmuxRgQYqOD3mHaU+MvZn5FLUeVxVfQjwLZW/n/JFuqg==}
select-hose@2.0.0:
resolution: {integrity: sha512-mEugaLK+YfkijB4fx0e6kImuJdCIt2LxCRcbEYPqRGCs4F2ogyfZU5IAZRdjCP8JPq2AtdNoC/Dux63d9Kiryg==}
@@ -9203,6 +9363,9 @@ packages:
shallow-copy@0.0.1:
resolution: {integrity: sha512-b6i4ZpVuUxB9h5gfCxPiusKYkqTMOjEbBs4wMaFbkfia4yFv92UKZ6Df8WXcKbn08JNL/abvg3FnMAOfakDvUw==}
shallowequal@1.1.0:
resolution: {integrity: sha512-y0m1JoUZSlPAjXVtPPW70aZWfIL/dSP7AFkRnniLCrK/8MDKog3TySTBmckD+RObVxH0v4Tox67+F14PdED2oQ==}
sharp@0.32.6:
resolution: {integrity: sha512-KyLTWwgcR9Oe4d9HwCwNM2l7+J0dUQwn/yf7S0EnTtb0eVS4RxO0eUSvxPtzT4F3SY+C4K6fqdv/DO27sJ/v/w==}
engines: {node: '>=14.15.0'}
@@ -9286,6 +9449,9 @@ packages:
resolution: {integrity: sha512-94hK0Hh8rPqQl2xXc3HsaBoOXKV20MToPkcXvwbISWLEs+64sBq5kFgn2kJDHb1Pry9yrP0dxrCI9RRci7RXKg==}
engines: {node: '>= 6.0.0', npm: '>= 3.0.0'}
smoothscroll-polyfill@0.4.4:
resolution: {integrity: sha512-TK5ZA9U5RqCwMpfoMq/l1mrH0JAR7y7KRvOBx0n2869aLxch+gT9GhN3yUfjiw+d/DiF1mKo14+hd62JyMmoBg==}
socks-proxy-agent@8.0.5:
resolution: {integrity: sha512-HehCEsotFqbPW9sJ8WVYB6UbmIMv7kUUORIF2Nncq4VQvBfNBLibW9YZR5dlYCSUhwcD628pRllm7n+E+YTzJw==}
engines: {node: '>= 14'}
@@ -9496,6 +9662,9 @@ packages:
resolution: {integrity: sha512-ki4hZQfh5rX0QDLLkOCj+h+CVNkqmp/CMf8v8kZpkNVK6jGQooMytqzLZYUVYIZcFZ6yDB70EfD8POcFXiF5oA==}
engines: {node: '>=18'}
styleq@0.2.1:
resolution: {integrity: sha512-L0TR0NQb+X4/ktDEKmjWyp27gla+LUYi/by5k5SjKXf6/pvZP7wbwEB5J+tqxdFVPgzbsuz+d4RTScO/QZquBw==}
sucrase@3.35.0:
resolution: {integrity: sha512-8EbVDiu9iN/nESwxeSxDKe0dunta1GOlHufmSSXxMD2z2/tMZpDMpvXQGsc+ajGo8y2uYUmixaSRUc/QPoQ0GA==}
engines: {node: '>=16 || 14 >=14.17'}
@@ -9547,6 +9716,9 @@ packages:
resolution: {integrity: sha512-Bh7QjT8/SuKUIfObSXNHNSK6WHo6J1tHCqJsuaFDP7gP0fkzSfTxI8y85JrppZ0h8l0maIgc2tfuZQ6/t3GtnQ==}
engines: {node: ^14.18.0 || >=16.0.0}
tabbable@6.5.0:
resolution: {integrity: sha512-wieBHXygIm7OyQOu5hQlkk62/WyCFYGlWg7L6/ZCUZwx0o398Zkn4pVmMyfYhfMG8kGrj/Krt8eIk6UKC6VzwA==}
table-layout@4.1.1:
resolution: {integrity: sha512-iK5/YhZxq5GO5z8wb0bY1317uDF3Zjpha0QFFLA8/trAoiLbQD0HUbMesEaxyzUgDxi2QlcbM8IvqOlEjgoXBA==}
engines: {node: '>=12.17'}
@@ -10030,6 +10202,11 @@ packages:
'@types/react':
optional: true
use-sync-external-store@1.6.0:
resolution: {integrity: sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w==}
peerDependencies:
react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
utif2@4.1.0:
resolution: {integrity: sha512-+oknB9FHrJ7oW7A2WZYajOcv4FcDR4CfoGB0dPNfxbi4GO05RRnFmt5oa23+9w32EanrYcSJWspUiJkLMs+37w==}
@@ -11474,6 +11651,39 @@ snapshots:
'@borewit/text-codec@0.2.2': {}
'@canva/app-ui-kit@5.12.0(@types/react@18.3.23)(mobx@6.16.1)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)':
dependencies:
'@floating-ui/react': 0.26.28(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
'@stylexjs/stylex': 0.18.3
classnames: 2.5.1
intl-messageformat: 10.7.18
mobx: 6.16.1
mobx-react-lite: 4.1.1(mobx@6.16.1)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
mobx-utils: 6.1.1(mobx@6.16.1)
normalize-scroll-left: 0.2.1
react: 18.3.1
react-dnd: 7.7.0(@types/react@18.3.23)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
react-dnd-html5-backend: 7.7.0
react-dom: 18.3.1(react@18.3.1)
react-transition-group: 4.4.5(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
resize-observer-polyfill: 1.5.1
seedrandom: 3.0.5
smoothscroll-polyfill: 0.4.4
tslib: 2.8.1
transitivePeerDependencies:
- '@types/react'
- react-native
'@canva/asset@2.3.0(@canva/error@2.2.1)':
dependencies:
'@canva/error': 2.2.1
'@canva/design@2.10.0(@canva/error@2.2.1)':
dependencies:
'@canva/error': 2.2.1
'@canva/error@2.2.1': {}
'@choojs/findup@0.2.1':
dependencies:
commander: 2.20.3
@@ -12023,8 +12233,42 @@ snapshots:
react: 18.3.1
react-dom: 18.3.1(react@18.3.1)
'@floating-ui/react@0.26.28(react-dom@18.3.1(react@18.3.1))(react@18.3.1)':
dependencies:
'@floating-ui/react-dom': 2.1.8(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
'@floating-ui/utils': 0.2.11
react: 18.3.1
react-dom: 18.3.1(react@18.3.1)
tabbable: 6.5.0
'@floating-ui/utils@0.2.11': {}
'@formatjs/ecma402-abstract@2.3.6':
dependencies:
'@formatjs/fast-memoize': 2.2.7
'@formatjs/intl-localematcher': 0.6.2
decimal.js: 10.6.0
tslib: 2.8.1
'@formatjs/fast-memoize@2.2.7':
dependencies:
tslib: 2.8.1
'@formatjs/icu-messageformat-parser@2.11.4':
dependencies:
'@formatjs/ecma402-abstract': 2.3.6
'@formatjs/icu-skeleton-parser': 1.8.16
tslib: 2.8.1
'@formatjs/icu-skeleton-parser@1.8.16':
dependencies:
'@formatjs/ecma402-abstract': 2.3.6
tslib: 2.8.1
'@formatjs/intl-localematcher@0.6.2':
dependencies:
tslib: 2.8.1
'@google/clasp@2.5.0(@types/node@22.7.5)(encoding@0.1.13)':
dependencies:
'@sindresorhus/is': 4.6.0
@@ -12077,7 +12321,7 @@ snapshots:
protobufjs: 7.6.3
yargs: 17.7.2
'@hanzo/ai@0.1.1(@types/react-dom@18.3.7(@types/react@18.3.23))(@types/react@18.3.23)(@zxing/library@0.22.0)(plotly.js@3.7.0(mapbox-gl@1.13.3))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.8.3)':
'@hanzo/ai@0.1.1(@types/react-dom@18.3.7(@types/react@18.3.23))(@types/react@18.3.23)(@zxing/library@0.22.0)(plotly.js@3.7.0(mapbox-gl@1.13.3))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.8.3)(use-sync-external-store@1.6.0(react@18.3.1))':
dependencies:
'@hookform/resolvers': 3.10.0(react-hook-form@7.80.0(react@18.3.1))
'@radix-ui/react-alert-dialog': 1.1.18(@types/react-dom@18.3.7(@types/react@18.3.23))(@types/react@18.3.23)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
@@ -12125,7 +12369,7 @@ snapshots:
ts-deepmerge: 7.0.3
yjs: 13.6.31
zod: 3.25.76
zustand: 5.0.14(@types/react@18.3.23)(immer@11.1.11)(react@18.3.1)
zustand: 5.0.14(@types/react@18.3.23)(immer@11.1.11)(react@18.3.1)(use-sync-external-store@1.6.0(react@18.3.1))
transitivePeerDependencies:
- '@emotion/is-prop-valid'
- '@types/react'
@@ -13979,6 +14223,12 @@ snapshots:
'@standard-schema/spec@1.1.0': {}
'@stylexjs/stylex@0.18.3':
dependencies:
css-mediaquery: 0.1.2
invariant: 2.2.4
styleq: 0.2.1
'@supabase/auth-js@2.70.0':
dependencies:
'@supabase/node-fetch': 2.6.15
@@ -14229,6 +14479,11 @@ snapshots:
dependencies:
'@types/unist': 3.0.3
'@types/hoist-non-react-statics@3.3.7(@types/react@18.3.23)':
dependencies:
'@types/react': 18.3.23
hoist-non-react-statics: 3.3.2
'@types/http-cache-semantics@4.2.0': {}
'@types/http-errors@2.0.5': {}
@@ -15125,6 +15380,8 @@ snapshots:
as-typed@1.3.2: {}
asap@2.0.6: {}
asn1@0.2.6:
dependencies:
safer-buffer: 2.1.2
@@ -15730,6 +15987,8 @@ snapshots:
clamp@1.0.1: {}
classnames@2.5.1: {}
clean-stack@2.2.0: {}
clean-stack@3.0.1:
@@ -16056,6 +16315,8 @@ snapshots:
css-global-keywords@1.0.1: {}
css-mediaquery@0.1.2: {}
css-select@5.2.2:
dependencies:
boolbase: 1.0.0
@@ -16307,6 +16568,12 @@ snapshots:
dependencies:
path-type: 4.0.0
dnd-core@7.7.0:
dependencies:
asap: 2.0.6
invariant: 2.2.4
redux: 4.2.1
doctrine@3.0.0:
dependencies:
esutils: 2.0.3
@@ -17570,6 +17837,10 @@ snapshots:
highlightjs-vue@1.0.0: {}
hoist-non-react-statics@3.3.2:
dependencies:
react-is: 16.13.1
hono@4.12.27: {}
hosted-git-info@4.1.0:
@@ -17814,6 +18085,17 @@ snapshots:
internmap@2.0.3: {}
intl-messageformat@10.7.18:
dependencies:
'@formatjs/ecma402-abstract': 2.3.6
'@formatjs/fast-memoize': 2.2.7
'@formatjs/icu-messageformat-parser': 2.11.4
tslib: 2.8.1
invariant@2.2.4:
dependencies:
loose-envify: 1.4.0
ip-address@10.1.1: {}
ip-address@10.2.0: {}
@@ -19059,6 +19341,20 @@ snapshots:
pkg-types: 1.3.1
ufo: 1.6.1
mobx-react-lite@4.1.1(mobx@6.16.1)(react-dom@18.3.1(react@18.3.1))(react@18.3.1):
dependencies:
mobx: 6.16.1
react: 18.3.1
use-sync-external-store: 1.6.0(react@18.3.1)
optionalDependencies:
react-dom: 18.3.1(react@18.3.1)
mobx-utils@6.1.1(mobx@6.16.1):
dependencies:
mobx: 6.16.1
mobx@6.16.1: {}
mocha@11.7.1:
dependencies:
browser-stdout: 1.3.1
@@ -19260,6 +19556,8 @@ snapshots:
normalize-path@3.0.0: {}
normalize-scroll-left@0.2.1: {}
normalize-svg-path@0.1.0: {}
normalize-svg-path@1.1.0:
@@ -19951,6 +20249,22 @@ snapshots:
minimist: 1.2.8
strip-json-comments: 2.0.1
react-dnd-html5-backend@7.7.0:
dependencies:
dnd-core: 7.7.0
react-dnd@7.7.0(@types/react@18.3.23)(react-dom@18.3.1(react@18.3.1))(react@18.3.1):
dependencies:
'@types/hoist-non-react-statics': 3.3.7(@types/react@18.3.23)
dnd-core: 7.7.0
hoist-non-react-statics: 3.3.2
invariant: 2.2.4
react: 18.3.1
react-dom: 18.3.1(react@18.3.1)
shallowequal: 1.1.0
transitivePeerDependencies:
- '@types/react'
react-dom@18.3.1(react@18.3.1):
dependencies:
loose-envify: 1.4.0
@@ -20178,6 +20492,10 @@ snapshots:
dependencies:
esprima: 4.0.1
redux@4.2.1:
dependencies:
'@babel/runtime': 7.29.7
reflect-metadata@0.2.2: {}
refractor@5.0.0:
@@ -20277,6 +20595,8 @@ snapshots:
requires-port@1.0.0: {}
resize-observer-polyfill@1.5.1: {}
resolve-alpn@1.2.1: {}
resolve-cwd@3.0.0:
@@ -20533,6 +20853,8 @@ snapshots:
dependencies:
loose-envify: 1.4.0
seedrandom@3.0.5: {}
select-hose@2.0.0: {}
semver@5.7.2: {}
@@ -20636,6 +20958,8 @@ snapshots:
shallow-copy@0.0.1: {}
shallowequal@1.1.0: {}
sharp@0.32.6:
dependencies:
color: 4.2.3
@@ -20771,6 +21095,8 @@ snapshots:
smart-buffer@4.2.0: {}
smoothscroll-polyfill@0.4.4: {}
socks-proxy-agent@8.0.5:
dependencies:
agent-base: 7.1.3
@@ -20996,6 +21322,8 @@ snapshots:
dependencies:
'@tokenizer/token': 0.3.0
styleq@0.2.1: {}
sucrase@3.35.0:
dependencies:
'@jridgewell/gen-mapping': 0.3.12
@@ -21058,6 +21386,8 @@ snapshots:
dependencies:
'@pkgr/core': 0.2.9
tabbable@6.5.0: {}
table-layout@4.1.1:
dependencies:
array-back: 6.2.2
@@ -21505,6 +21835,10 @@ snapshots:
optionalDependencies:
'@types/react': 18.3.23
use-sync-external-store@1.6.0(react@18.3.1):
dependencies:
react: 18.3.1
utif2@4.1.0:
dependencies:
pako: 1.0.11
@@ -22257,8 +22591,9 @@ snapshots:
zod@3.25.76: {}
zustand@5.0.14(@types/react@18.3.23)(immer@11.1.11)(react@18.3.1):
zustand@5.0.14(@types/react@18.3.23)(immer@11.1.11)(react@18.3.1)(use-sync-external-store@1.6.0(react@18.3.1)):
optionalDependencies:
'@types/react': 18.3.23
immer: 11.1.11
react: 18.3.1
use-sync-external-store: 1.6.0(react@18.3.1)
+1
View File
@@ -3,6 +3,7 @@ packages:
- 'packages/aci'
- 'packages/auth'
- 'packages/browser'
- 'packages/canva'
- 'packages/cards'
- 'packages/clio'
- 'packages/docusign'