Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,9 @@ nobody hosts it yet: run it yourself, or hand over the `.flow` file or the brief
- **Frames.** A frame (from the library) is a named region behind the shapes inside it: dragging
it carries them along, in one undo step. From its menu, copy just that frame for AI or export it
on its own. The brief groups shapes by frame, and Mermaid draws frames as subgraphs.
- **Draft with your agent.** Describe a diagram in words, and isketch writes a prompt that teaches
the `.flow` format: copy it, or open it straight in Claude or ChatGPT, then paste the answer back
and it is drawn, undoably. Your own agent does the drafting; isketch sends nothing anywhere.
- **Quick to type.** On a blank diagram, just start typing: the first shape takes the words.
Double-click empty canvas for a shape there. `Tab` adds the next step below the selected shape,
connected and ready to name, as in Whimsical. `Ctrl+K` finds any action or shape by name.
Expand Down
38 changes: 35 additions & 3 deletions backlog/tasks/fl-90 - Diagram-from-words.md
Original file line number Diff line number Diff line change
@@ -1,9 +1,11 @@
---
id: FL-90
title: Diagram from words
status: To Do
assignee: []
status: In Progress
assignee:
- '@raj-khan'
created_date: '2026-09-25 17:39'
updated_date: '2026-09-27 14:40'
labels:
- ai
milestone: m-3
Expand All @@ -24,7 +26,37 @@ Describe a system in a sentence and get a .flow diagram, built on the text forma

<!-- AC:BEGIN -->

- [ ] #1 A Draft with your agent prompt, copyable from the app, that makes any agent return valid .flow
- [x] #1 A Draft with your agent prompt, copyable from the app, that makes any agent return valid .flow
- [ ] #2 A built-in version is decided by the owner (cost of an API key or server)

<!-- AC:END -->

## Implementation Plan

<!-- SECTION:PLAN:BEGIN -->

1. src/domain/draft.js: draftPrompt(description) teaches the .flow format (rules, every shape from the registry, a valid example) and asks for one fenced block; readDraft(answer) takes the first fenced block or the whole answer, with line errors; AGENT_LINKS opens the prompt filled in at Claude and ChatGPT.
2. DraftDialog: describe, copy or open the prompt, paste the answer (live status), Use this diagram as one undoable change. In the menu and the command palette.
3. The built-in version (AC #2) is the owners decision on cost; asked, not built.

<!-- SECTION:PLAN:END -->

## Implementation Notes

<!-- SECTION:NOTES:BEGIN -->

Verified: draft.spec.js (6: description, every shape and the rules in the prompt; its example parses; agent links; fenced and unfenced answers; errors by line; an answer with no shapes); e2e/draft.spec.js (copy, link, a wrong answer blocked with its line, a real-looking answer drawn, undo). Tried on a real model: claude -p --model haiku with the prompt for a URL shortener returned valid .flow at once (8 shapes, 7 connections, every part named). vitest and e2e green.
<!-- SECTION:NOTES:END -->

## Comments

<!-- COMMENTS:BEGIN -->

author: @claude
created: 2026-09-27 14:40
---

For the owner: a built-in "Describe and draw" needs either the persons own API key in the browser (no cost to you, a little setup for them) or a call through the isketch server with your key (simplest for them, costs you per diagram, needs rate limits). Which, if either?
---

<!-- COMMENTS:END -->
64 changes: 64 additions & 0 deletions e2e/draft.spec.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
import { expect, test } from '@playwright/test'

import { fromMenu, history } from './helpers.js'

const shapes = (page) => page.locator('.vue-flow__node')

const ANSWER = `Here is your diagram:

\`\`\`flow
title: Checkout
note: Failed payments retry

web = screen "Web app" -- /checkout
pay = process "Payments API"
stripe = terminal "Stripe"
db = database "Orders"

web -> pay : HTTPS
pay -> stripe : charge
pay -> db : write order
pay --> web : retry
\`\`\`

Tell me if you want the refunds flow too.`

test('drafts a diagram with your agent: copy the prompt, paste the answer, undo', async ({
page,
context,
}) => {
await context.grantPermissions(['clipboard-read', 'clipboard-write'])
await page.goto('/new')
await expect(shapes(page)).toHaveCount(5)

await fromMenu(page, 'Draft with your agent')
const dialog = page.getByRole('dialog', { name: 'Draft with your agent' })
const copy = dialog.getByRole('button', { name: 'Copy prompt' })
await expect(copy).toBeDisabled()

await dialog
.getByLabel('1. Describe the diagram')
.fill('A checkout that charges Stripe and writes orders.')
await copy.click()
const prompt = await page.evaluate(() => navigator.clipboard.readText())
expect(prompt).toContain('A checkout that charges Stripe and writes orders.')
expect(prompt).toContain('Reply with only the diagram')
await expect(dialog.getByRole('link', { name: 'Open in Claude' })).toHaveAttribute(
'href',
/^https:\/\/claude\.ai\/new\?q=Draw%20this/,
)

const answer = dialog.getByLabel('2. Paste the answer')
await answer.fill('```flow\ntitle: X\na = blob "A"\n```')
await expect(dialog.getByRole('status')).toContainText('Line 2:')
await expect(dialog.getByRole('button', { name: 'Use this diagram' })).toBeDisabled()

await answer.fill(ANSWER)
await expect(dialog.getByRole('status')).toHaveText('4 shapes and 4 connections, ready.')
await dialog.getByRole('button', { name: 'Use this diagram' }).click()

await expect(shapes(page)).toHaveCount(4)
await expect(page.locator('.vue-flow__node[data-id="stripe"]')).toContainText('Stripe')
await history(page).getByRole('button', { name: 'Undo' }).click()
await expect(shapes(page)).toHaveCount(5)
})
144 changes: 144 additions & 0 deletions src/components/draft/DraftDialog.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,144 @@
<script setup>
import { computed, ref } from 'vue'

import BaseModal from '@/components/ui/BaseModal.vue'
import { useFlowHistory } from '@/composables/useFlowHistory.js'
import { useReplaceDocument } from '@/composables/useNodeMutations.js'
import { AGENT_LINKS, draftPrompt, readDraft } from '@/domain/draft.js'
import { useCanvasStore } from '@/stores/canvas.js'
import { useFileStore } from '@/stores/file.js'
import { useToastStore } from '@/stores/toasts.js'

/**
* A diagram from words, drafted by the agent the person already uses: describe
* it, take the prompt to the agent, paste the answer back. Nothing is sent
* anywhere by isketch.
*/
const emit = defineEmits(['close'])

const description = ref('')
const answer = ref('')
const copied = ref(false)
const toasts = useToastStore()
const canvas = useCanvasStore()
const file = useFileStore()
const replace = useReplaceDocument('Draft from words')
const { undo } = useFlowHistory()

const prompt = computed(() => (description.value.trim() ? draftPrompt(description.value) : ''))
const draft = computed(() => (answer.value.trim() ? readDraft(answer.value) : null))
const status = computed(() => {
if (!draft.value) return 'Paste the agent’s answer here; talk around the diagram is fine.'
const { document, errors } = draft.value
if (!document) return `Line ${errors[0].line}: ${errors[0].message}`
const shapes = document.nodes.length
const lines = document.edges.length
return `${shapes} shape${shapes === 1 ? '' : 's'} and ${lines} connection${lines === 1 ? '' : 's'}, ready.`
})

async function copyPrompt() {
try {
await navigator.clipboard.writeText(prompt.value)
copied.value = true
setTimeout(() => (copied.value = false), 1500)
} catch {
toasts.push('The prompt could not be copied. Your browser refused the clipboard.', {
tone: 'danger',
})
}
}

function use() {
const document = draft.value?.document
if (!document) return
canvas.forgetViewport()
file.forget()
replace.mutate(document, {
onSuccess: () =>
toasts.push('Drawn from the agent’s answer. Undo brings yours back.', {
action: { label: 'Undo', run: undo },
}),
})
emit('close')
}

const button =
'rounded-lg border border-line px-3 py-1.5 text-sm transition-colors hover:bg-hover aria-disabled:pointer-events-none aria-disabled:opacity-40'
const field =
'w-full resize-y rounded-lg border border-line bg-sunken px-3 py-2 text-sm text-ink outline-none focus:border-focus'
</script>

<template>
<BaseModal title="Draft with your agent" @close="emit('close')">
<div class="space-y-4 px-5 py-4">
<section class="space-y-2">
<label class="block space-y-1.5">
<span class="text-xs font-medium text-muted">1. Describe the diagram</span>
<textarea
v-model="description"
rows="4"
:class="field"
placeholder="A checkout: the web app calls a payments API, which charges Stripe and writes orders to PostgreSQL. Failed payments retry."
/>
</label>
<p class="text-xs text-muted">
The prompt teaches the agent the .flow format and asks for your diagram in it. Your own
Claude, ChatGPT or coding agent drafts it; isketch sends nothing anywhere.
</p>
<div class="flex flex-wrap gap-2">
<button
type="button"
:class="button"
:aria-disabled="!prompt"
:disabled="!prompt"
@click="copyPrompt"
>
{{ copied ? 'Copied' : 'Copy prompt' }}
</button>
<a
v-for="agent in AGENT_LINKS"
:key="agent.id"
:class="button"
:href="prompt ? agent.url(prompt) : undefined"
:aria-disabled="!prompt"
target="_blank"
rel="noopener"
>
{{ agent.label }}
</a>
</div>
</section>

<section class="space-y-2 border-t border-line pt-4">
<label class="block space-y-1.5">
<span class="text-xs font-medium text-muted">2. Paste the answer</span>
<textarea
v-model="answer"
rows="5"
:class="[field, 'font-mono text-xs']"
spellcheck="false"
aria-describedby="draft-status"
/>
</label>
<p
id="draft-status"
class="text-xs"
:class="draft && !draft.document ? 'text-danger' : 'text-muted'"
role="status"
>
{{ status }}
</p>
<div class="flex justify-end">
<button
type="button"
class="rounded-lg bg-brand px-4 py-2 text-sm font-medium text-brand-ink transition-opacity hover:opacity-90 disabled:opacity-40"
:disabled="!draft?.document"
@click="use"
>
Use this diagram
</button>
</div>
</section>
</div>
</BaseModal>
</template>
3 changes: 2 additions & 1 deletion src/components/shell/MainMenu.vue
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ import { useToastStore } from '@/stores/toasts.js'
* The top-left island: the product, the open file, and a menu of everything
* that is not drawing. It owns Ctrl+O and Ctrl+S, bound once here.
*/
const emit = defineEmits(['help', 'import', 'compare', 'export', 'commands'])
const emit = defineEmits(['help', 'import', 'compare', 'export', 'commands', 'draft'])

const canvas = useCanvasStore()
const file = useFileStore()
Expand Down Expand Up @@ -52,6 +52,7 @@ const groups = computed(
{ label: 'Save', run: save, hint: comboLabel(COMBO.SAVE, isMac.value) },
],
[
{ label: 'Draft with your agent', run: () => emit('draft') },
{ label: 'Import', run: () => emit('import') },
{ label: 'Export', run: () => emit('export') },
{ label: 'Compare', run: () => emit('compare') },
Expand Down
9 changes: 8 additions & 1 deletion src/composables/useCommands.js
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ import { useToastStore } from '@/stores/toasts.js'
* the view's and one "Add" for each shape in the registry, so a new shape or
* tool shows up here without a line of its own.
*
* @param {{ open: (dialog: 'import' | 'export' | 'compare' | 'share' | 'help') => void }} shell
* @param {{ open: (dialog: 'import' | 'export' | 'compare' | 'share' | 'help' | 'draft') => void }} shell
*/
export function useCommands(shell) {
const canvas = useCanvasStore()
Expand Down Expand Up @@ -66,6 +66,13 @@ export function useCommands(shell) {
},
{ id: 'open', label: 'Open file', group: 'File', hint: key(COMBO.OPEN), run: open },
{ id: 'save', label: 'Save', group: 'File', hint: key(COMBO.SAVE), run: save },
{
id: 'draft',
label: 'Draft with your agent',
group: 'File',
keywords: 'describe words ai generate claude chatgpt prompt',
run: () => shell.open('draft'),
},
{
id: 'import',
label: 'Import',
Expand Down
58 changes: 58 additions & 0 deletions src/domain/__tests__/draft.spec.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
import { describe, expect, it } from 'vitest'

import { AGENT_LINKS, draftPrompt, readDraft } from '../draft.js'
import { parseFlow } from '../flowText.js'
import { SHAPE_OPTIONS } from '../nodeMeta.js'

describe('draftPrompt', () => {
const prompt = draftPrompt(
' A checkout: the cart calls a payment API, which writes to an orders database. ',
)

it('carries the description, every shape and the rules that make valid .flow', () => {
expect(prompt).toContain(
'What to draw:\nA checkout: the cart calls a payment API, which writes to an orders database.\n',
)
for (const option of SHAPE_OPTIONS) expect(prompt).toContain(`- \`${option.value}\`: `)
expect(prompt).toContain('Leave out any @layout block')
expect(prompt).toContain('Reply with only the diagram, in one ```flow code block.')
})

it('teaches with an example that is itself valid', () => {
const example = /```flow\n([\s\S]*?)```/.exec(prompt)[1]
const { document, errors } = parseFlow(example)
expect(errors).toEqual([])
expect(document.nodes).toHaveLength(4)
})

it('opens in Claude and ChatGPT, filled in', () => {
expect(AGENT_LINKS.map((link) => link.url('a b&c'))).toEqual([
'https://claude.ai/new?q=a%20b%26c',
'https://chatgpt.com/?q=a%20b%26c',
])
})
})

describe('readDraft', () => {
it('reads the fenced block of an answer, and ignores the talk around it', () => {
const answer =
'Here is the diagram:\n\n```flow\ntitle: Checkout\ncart = screen "Cart"\npay = process "Payments"\ncart -> pay : checkout\n```\n\nLet me know!'
const { document, errors } = readDraft(answer)
expect(errors).toEqual([])
expect(document.title).toBe('Checkout')
expect(document.edges).toHaveLength(1)
})

it('reads an answer with no fence, and says where one is wrong', () => {
expect(readDraft('a = process "A"').document.nodes).toHaveLength(1)
const wrong = readDraft('```\ntitle: X\na = blob "A"\n```')
expect(wrong.document).toBeNull()
expect(wrong.errors[0].line).toBe(2)
})

it('refuses an answer with no shapes in it', () => {
expect(readDraft('```flow\ntitle: Nothing\n```').errors).toEqual([
{ line: 1, message: 'There are no shapes in this answer.' },
])
})
})
Loading
Loading