diff --git a/AGENTS.md b/AGENTS.md index 0cef486..00f4762 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -30,6 +30,9 @@ tests the previous build. `VITE_ISKETCH_API` names a server. - Per shape behaviour lives in `src/domain/nodeMeta.js`, and each outline in `src/domain/shapes.js`. Add an entry there rather than branching on type in a component. +- `plugin/server/isketch-mcp.mjs` is a bundle of `src/mcp/server.js` and its `src/domain/` + dependencies, for the Claude Code plugin. After changing either, run `npm run plugin` and + commit the rebuilt bundle; CI fails if it is stale. - State has three owners and no copies: TanStack Query owns the document, the route owns which node is open, Pinia owns viewport, history, theme and toasts. Do not mirror one in another. diff --git a/README.md b/README.md index 591968d..f99657b 100644 --- a/README.md +++ b/README.md @@ -13,6 +13,8 @@ Claude, Copilot or any coding agent reads without guessing, and can edit back. [![CI](https://github.com/raj-khan/flow/actions/workflows/ci.yml/badge.svg)](https://github.com/raj-khan/flow/actions/workflows/ci.yml) [![License: MIT](https://img.shields.io/badge/License-MIT-ff5a2c.svg)](LICENSE) +![Sketching in isketch, Copy for AI, and an agent building from the brief](docs/loop.gif) + https://github.com/user-attachments/assets/cbfbc844-7373-4891-a985-50e2870fe1b5 diff --git a/backlog/tasks/fl-91 - Launch-kit.md b/backlog/tasks/fl-91 - Launch-kit.md index 3a32d50..e5bb737 100644 --- a/backlog/tasks/fl-91 - Launch-kit.md +++ b/backlog/tasks/fl-91 - Launch-kit.md @@ -1,10 +1,11 @@ --- id: FL-91 title: Launch kit -status: To Do +status: In Progress assignee: - '@raj-khan' created_date: '2026-09-25 17:39' +updated_date: '2026-09-27 14:52' labels: - launch milestone: m-4 @@ -31,6 +32,24 @@ Mostly the owner's to do, listed so it is not forgotten. - [ ] #1 A 60 second demo video (made with idemo.video) and a README hero GIF of the loop - [ ] #2 Posts: Show HN, Product Hunt, r/ClaudeAI, r/ChatGPTCoding, dev.to, X - [ ] #3 Listings: awesome-mcp-servers, awesome-claude-code, AlternativeTo against Excalidraw, draw.io and Eraser -- [ ] #4 An optional, removable Made with isketch mark on exported PNGs and hosted pages +- [x] #4 An optional, removable Made with isketch mark on exported PNGs and hosted pages + +## Implementation Plan + + + +1. What lives in the repo, done here: a README hero GIF of the loop (recorded with the clip, encoded in the browser with gifenc, since there is no ffmpeg), the landing clip re-recorded with the current UI, and an optional Made with isketch mark on exported pictures (on by default, one checkbox to take it off, remembered) and hosted pages. +2. What is the owners: the 60 second demo video (idemo.video), the posts and the listings; drafted ready to post in docs/launch.md, with the registry and marketplace steps in docs/listings.md. + + + +## Implementation Notes + + + +Done in the repo: docs/loop.gif (92 frames, 3.1 MB, 720px) from npm run clip, which also re-records public/demo.webm; renderSvg credit option; Export checkbox; hosted page footer credit. Verified: renderSvg spec, export e2e (mark on by default, off when unticked and remembered), server test for the page footer; vitest 312, e2e 146, server 16. + +Left for the owner: AC #1 needs the idemo.video demo (the GIF half is done); AC #2 posts and AC #3 listings are drafted in docs/launch.md and docs/listings.md and need the owners accounts, after isketch.online is live. + diff --git a/docs/launch.md b/docs/launch.md new file mode 100644 index 0000000..dcc11ec --- /dev/null +++ b/docs/launch.md @@ -0,0 +1,90 @@ +# Launch kit + +Drafts to post, in the owner's voice. Each points at the README, whose hero GIF shows the loop. +Post after isketch.online serves the app and the hosted server, so every link works, and after the +60 second demo video (made with idemo.video) is uploaded to the README. + +## Show HN + +**Title:** Show HN: isketch, a sketchpad your coding agent reads exactly + +**Text:** + +I kept sketching architectures in Excalidraw, screenshotting them, and pasting the picture into +Claude. The agent then guessed at boxes and arrows from pixels: misread names, lost arrow +directions, and nothing it wrote back could go on the diagram. + +isketch is a sketchpad where every sketch is also plain text, a `.flow` file with ids, kinds, +directions and notes. Copy for AI puts a Markdown brief on the clipboard; an MCP server lets +Claude Code or any agent list, read, validate, render and edit the diagrams in your repo; and when +the agent writes the file, the open canvas shows the change as it happens. + +It imports draw.io, Excalidraw, Mermaid, docker-compose, OpenAPI, SQL, Prisma and Drizzle, +`isketch scan` drafts a diagram of a whole repository, and pull requests get a visual diff. It works +offline, needs no account, and is MIT licensed. + +https://github.com/raj-khan/flow + +## Product Hunt + +- **Tagline:** Sketch it, hand it to your agent +- **Description:** A sketchpad whose output is agent-ready: every diagram is also text that Claude, + Copilot or any coding agent reads exactly and edits back. Hand-drawn look, draw.io and + Excalidraw import, an MCP server, and a Claude Code plugin. +- **First comment:** why screenshots fail agents (names misread, directions lost, nothing to write + back), the loop in one line, and one ask: which import should come next. + +## r/ClaudeAI + +**Title:** I stopped pasting diagram screenshots into Claude. Now it reads and edits the diagram itself. + +**Body:** the loop (sketch, Copy for AI or the MCP server, Claude builds, Claude updates the +`.flow` file, the canvas shows it live), the plugin install lines, and a short clip. Ask what +people would want Claude to do with a diagram next. + +```text +/plugin marketplace add raj-khan/flow +/plugin install isketch@isketch +``` + +## r/ChatGPTCoding + +**Title:** Diagrams your coding agent can read: text, not pixels + +**Body:** the same loop, told for any agent: Draft with your agent (describe, paste the answer), +Copy for AI, and the `.flow` file an agent edits in the repo. Lead with the before and after of a +screenshot against a brief. + +## dev.to + +**Title:** Stop screenshotting your Excalidraw for Claude + +**Outline:** + +1. The screenshot habit, and three ways it fails an agent. +2. What an agent needs instead: ids, kinds, directions, notes, as text. +3. The `.flow` format in ten lines. +4. The loop in practice: sketch, brief, build, the agent keeps the diagram true. +5. Bringing existing diagrams along: draw.io, Excalidraw, Mermaid, schemas, `isketch scan`. +6. Where it goes next. + +## X + +1. Pasting a diagram screenshot into Claude makes it guess. isketch makes every sketch text it + reads exactly. (GIF) +2. Copy for AI: a Markdown brief with every shape, what it means, and every connection in words. +3. Or skip the clipboard: the MCP server lets Claude Code read, write and render your diagrams, + and the open canvas shows its edits live. +4. Bring what you have: draw.io, Excalidraw, Mermaid, compose, OpenAPI, SQL, Prisma, Drizzle. +5. MIT, offline, no account: github.com/raj-khan/flow + +## Listings + +Ready to paste; the MCP registry and marketplaces are in [listings.md](listings.md). + +- **awesome-mcp-servers:** `- [raj-khan/flow](https://github.com/raj-khan/flow) - isketch: read, +write, validate, render and diff .flow architecture diagrams an agent reads exactly.` +- **awesome-claude-code:** under plugins, `isketch: the .flow diagram format as a skill, with an MCP +server to read, write and render diagrams in your repo.` +- **AlternativeTo:** list isketch as an alternative to Excalidraw, draw.io and Eraser, with the + tagline and the GIF; tags: diagrams, whiteboard, AI, developer tools. diff --git a/docs/loop.gif b/docs/loop.gif new file mode 100644 index 0000000..d17aeaa Binary files /dev/null and b/docs/loop.gif differ diff --git a/e2e/export.spec.js b/e2e/export.spec.js index 5dfa19c..66758f9 100644 --- a/e2e/export.spec.js +++ b/e2e/export.spec.js @@ -42,3 +42,16 @@ test('downloads a sketch as SVG, carrying its handwriting font', async ({ page } expect(svg).toMatch(/^ { + expect((await exportAs(page, 'SVG')).bytes.toString()).toContain('Made with isketch') + + await fromMenu(page, 'Export') + const dialog = page.getByRole('dialog', { name: 'Export' }) + await dialog.getByLabel(/Made with isketch/).uncheck() + await page.keyboard.press('Escape') + + expect((await exportAs(page, 'SVG')).bytes.toString()).not.toContain('Made with isketch') +}) diff --git a/package-lock.json b/package-lock.json index 2b5addc..c7e40a8 100644 --- a/package-lock.json +++ b/package-lock.json @@ -36,6 +36,7 @@ "@vue/test-utils": "^2.5.1", "eslint": "^10.11.0", "eslint-plugin-vue": "^10.11.0", + "gifenc": "^1.0.3", "happy-dom": "^20.14.5", "husky": "^9.1.7", "lint-staged": "^17.5.1", @@ -2743,6 +2744,13 @@ "node": "^8.16.0 || ^10.6.0 || >=11.0.0" } }, + "node_modules/gifenc": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/gifenc/-/gifenc-1.0.3.tgz", + "integrity": "sha512-xdr6AdrfGBcfzncONUOlXMBuc5wJDtOueE3c5rdG0oNgtINLD+f2iFZltrBRZYzACRbKr+mSVU/x98zv2u3jmw==", + "dev": true, + "license": "MIT" + }, "node_modules/glob": { "version": "13.0.6", "resolved": "https://registry.npmjs.org/glob/-/glob-13.0.6.tgz", diff --git a/package.json b/package.json index 8af5361..0520ca9 100644 --- a/package.json +++ b/package.json @@ -55,6 +55,7 @@ "@vue/test-utils": "^2.5.1", "eslint": "^10.11.0", "eslint-plugin-vue": "^10.11.0", + "gifenc": "^1.0.3", "happy-dom": "^20.14.5", "husky": "^9.1.7", "lint-staged": "^17.5.1", diff --git a/plugin/server/isketch-mcp.mjs b/plugin/server/isketch-mcp.mjs index 2c3dba4..2c4a9d0 100644 --- a/plugin/server/isketch-mcp.mjs +++ b/plugin/server/isketch-mcp.mjs @@ -3191,12 +3191,13 @@ const GLYPH = .56; * lays them out. * * @param {import('./types.js').FlowDocument} document -* @param {{ theme?: 'light' | 'dark', padding?: number, highlight?: Map, sketchFont?: string }} [options] +* @param {{ theme?: 'light' | 'dark', padding?: number, highlight?: Map, sketchFont?: string, credit?: boolean }} [options] * `highlight` marks nodes and edges by id, for a diff; `sketchFont` is the handwriting -* font as a data URL, embedded in a sketch so it looks the same wherever it opens +* font as a data URL, embedded in a sketch so it looks the same wherever it opens; +* `credit` adds a small "Made with isketch" in the bottom right corner, linked * @returns {string} */ -function renderSvg(document, { theme = "light", padding = 32, highlight = /* @__PURE__ */ new Map(), sketchFont = "" } = {}) { +function renderSvg(document, { theme = "light", padding = 32, highlight = /* @__PURE__ */ new Map(), sketchFont = "", credit = false } = {}) { const colours = SVG_THEMES[theme] ?? SVG_THEMES.light; const nodes = document.nodes.map(normaliseNode); const ids = new Set(nodes.map((node) => node.id)); @@ -3234,6 +3235,7 @@ function renderSvg(document, { theme = "light", padding = 32, highlight = /* @__ ...sizes.get(edge.target) }, colours, highlight.get(edge.id), sketch, document.lines)), ...nodes.filter((node) => node.type !== SHAPE.FRAME).map((node) => renderNode(node, at.get(node.id), colours, highlight.get(node.id), sketch)), + credit ? `Made with isketch` : "", "" ].filter(Boolean).join("\n")}\n`; } diff --git a/public/demo.webm b/public/demo.webm index f118596..f194d80 100644 Binary files a/public/demo.webm and b/public/demo.webm differ diff --git a/scripts/make-demo-clip.mjs b/scripts/make-demo-clip.mjs index c8225e1..612b54d 100644 --- a/scripts/make-demo-clip.mjs +++ b/scripts/make-demo-clip.mjs @@ -2,10 +2,12 @@ * Records the landing clip: the real app sketching, Copy for AI, then an * agent's answer building from the brief. One take, scripted, against the * production build on a local server; the webm is committed as - * public/demo.webm. Run with `npm run clip`. + * public/demo.webm. The same take, as screenshots, becomes docs/loop.gif for + * the README, encoded in the browser with gifenc. Run with `npm run clip`. */ import { spawn } from 'node:child_process' -import { cp, mkdtemp, readdir, rm, readFile } from 'node:fs/promises' +import { cp, mkdtemp, readdir, rm, readFile, writeFile } from 'node:fs/promises' +import { createRequire } from 'node:module' import { tmpdir } from 'node:os' import { join } from 'node:path' @@ -33,6 +35,18 @@ try { // 1 · Sketch: the starter diagram, a shape moved, a title renamed in place. await page.goto(`http://localhost:${PORT}/flow`) await nodeAt(page, 'b6a0c1').waitFor({ state: 'visible' }) + // The README's GIF: a screenshot every few frames, with when it was taken. + /** @type {{ at: number, jpeg: string }[]} */ + const frames = [] + let filming = true + const film = (async () => { + while (filming) { + const jpeg = await page.screenshot({ type: 'jpeg', quality: 85 }).catch(() => null) + if (jpeg) frames.push({ at: Date.now(), jpeg: jpeg.toString('base64') }) + await pause(90) + } + })() + await pause(1200) const card = nodeAt(page, 'b6a0c1') @@ -47,6 +61,8 @@ try { await pause(400) await page.keyboard.type('Support bot', { delay: 45 }) await page.keyboard.press('Enter') + // The click that began the rename opened the details too; the take is about the canvas. + await page.keyboard.press('Escape') await pause(1000) // 2 · Hand it over: the brief goes onto the clipboard. @@ -60,6 +76,9 @@ try { await page.locator('.clip-code').waitFor({ state: 'visible' }) await pause(2800) + filming = false + await film + await writeGif(frames) await context.close() } finally { await browser.close() @@ -75,6 +94,54 @@ if (webm) { } await rm(take, { recursive: true, force: true }) +/** + * The frames as a looping GIF, 720px wide: decoded and quantised in the + * browser (Node has no image decoder here), each shown for as long as it was + * on screen. + * @param {{ at: number, jpeg: string }[]} frames + */ +async function writeGif(frames) { + const encoder = await readFile( + createRequire(import.meta.url).resolve('gifenc/dist/gifenc.esm.js'), + 'utf8', + ) + const page = await browser.newPage() + await page.goto(`http://localhost:${PORT}/docs/format/`) + const bytes = await page.evaluate( + async ({ code, frames }) => { + const url = URL.createObjectURL(new Blob([code], { type: 'text/javascript' })) + const { GIFEncoder, quantize, applyPalette } = await import(url) + const width = 720 + const height = 405 + const canvas = new globalThis.OffscreenCanvas(width, height) + const context = canvas.getContext('2d', { willReadFrequently: true }) + const gif = GIFEncoder() + for (let index = 0; index < frames.length; index++) { + const image = await globalThis.createImageBitmap( + await (await fetch(`data:image/jpeg;base64,${frames[index].jpeg}`)).blob(), + ) + context.drawImage(image, 0, 0, width, height) + const { data } = context.getImageData(0, 0, width, height) + const palette = quantize(data, 256) + const next = frames[index + 1]?.at ?? frames[index].at + 1500 + gif.writeFrame(applyPalette(data, palette), width, height, { + palette, + delay: Math.max(20, next - frames[index].at), + }) + } + gif.finish() + return Array.from(gif.bytes()) + }, + { code: encoder, frames }, + ) + await page.close() + const gif = new URL('../docs/loop.gif', import.meta.url).pathname + await writeFile(gif, Buffer.from(bytes)) + console.log( + `docs/loop.gif (${frames.length} frames, ${(bytes.length / 1024 / 1024).toFixed(1)} MB)`, + ) +} + /** @param {string} url */ async function waitUntil(url) { for (let attempt = 0; attempt < 60; attempt++) { @@ -127,14 +194,17 @@ function addChat() { chat.innerHTML = `
You
Build this. Here is the diagram as a brief:
-
**Support bot** → **Escalation** …
+    
**Business Hours** → **Support bot**: Failure
+**Support bot** → **Add Comment #1** …
 
-The brief itself stays on your clipboard —
-in the real thing you paste all of it here.
+(In the real thing, the whole brief is pasted here.)
Claude
-
Scaffolding the services the sketch names
-
const escalation = await page.getByRole('button', { name: 'Away' })
-// support-bot/escalation.ts — from the sketch, not a guess
+
Building the steps the sketch names
+
// support-bot.ts: named from the sketch, not guessed
+export async function handle(conversation) {
+  if (!isBusinessHours()) return supportBot(conversation)
+  return welcomeMessage(conversation)
+}
` document.body.append(chat) setTimeout(() => chat.classList.add('typing'), 2400) diff --git a/server/src/diagrams/page.ts b/server/src/diagrams/page.ts index e0c8967..508a199 100644 --- a/server/src/diagrams/page.ts +++ b/server/src/diagrams/page.ts @@ -69,7 +69,7 @@ export function renderPage(input: {

Brief

For people and AI agents alike: every shape, what it is for, every connection, and the source.

${escape(brief)}
- + diff --git a/server/test/diagrams.test.ts b/server/test/diagrams.test.ts index b071c69..c37271a 100644 --- a/server/test/diagrams.test.ts +++ b/server/test/diagrams.test.ts @@ -115,6 +115,7 @@ describe('reading a link', () => { assert.match(html, /Shop · isketch<\/title>/) assert.match(html, /\*\*API\*\* → \*\*Orders\*\*: SQL/) assert.match(html, /href="https:\/\/app\.isketch\.test\/flow#flow=z[\w-]+"/) + assert.match(html, /Made with <a href="https:\/\/app\.isketch\.test">isketch<\/a>/) assert.equal(page.headers.get('x-robots-tag'), 'noindex') }) diff --git a/src/api/storageKeys.js b/src/api/storageKeys.js index 38c1111..b780deb 100644 --- a/src/api/storageKeys.js +++ b/src/api/storageKeys.js @@ -4,6 +4,7 @@ export const STORAGE_KEYS = Object.freeze({ THEME: 'flow:theme', SNAP: 'flow:snap', MINIMAP: 'flow:minimap', + CREDIT: 'flow:credit', PUBLISHED: 'flow:published', }) diff --git a/src/components/export/ExportDialog.vue b/src/components/export/ExportDialog.vue index 597e96c..f870ffc 100644 --- a/src/components/export/ExportDialog.vue +++ b/src/components/export/ExportDialog.vue @@ -1,5 +1,5 @@ <script setup> -import { computed, onBeforeUnmount, ref, watchEffect } from 'vue' +import { computed, onBeforeUnmount, ref, watch, watchEffect } from 'vue' import { track } from '@/api/analytics.js' import BaseModal from '@/components/ui/BaseModal.vue' @@ -12,6 +12,7 @@ import { flowFileName } from '@/domain/flowText.js' import { frameDocument, isFrame } from '@/domain/frames.js' import { renderSvg } from '@/domain/renderSvg.js' import { isSketch } from '@/domain/sketch.js' +import { STORAGE_KEYS } from '@/api/storageKeys.js' import { useCanvasStore } from '@/stores/canvas.js' import { useThemeStore } from '@/stores/theme.js' import { useToastStore } from '@/stores/toasts.js' @@ -55,9 +56,27 @@ watchEffect(async () => { if (isSketch(document.value) && !font.value) font.value = await sketchFontData() }) +/** A small "Made with isketch" on pictures, unless it is taken off; remembered here. */ +const credit = ref(readCredit()) +function readCredit() { + try { + return localStorage.getItem(STORAGE_KEYS.CREDIT) !== 'off' + } catch { + return true + } +} +watch(credit, (on) => { + try { + localStorage.setItem(STORAGE_KEYS.CREDIT, on ? 'on' : 'off') + } catch { + // A private window refuses storage; the choice holds for this export. + } +}) + const svg = computed(() => document.value ? renderSvg(document.value, { + credit: credit.value, theme: look.value === 'dark' ? 'dark' : 'light', sketchFont: font.value, }) @@ -122,6 +141,11 @@ async function download() { </select> </label> + <label v-if="!isFile" class="flex items-center gap-2 text-sm"> + <input v-model="credit" type="checkbox" /> + A small “Made with isketch” in the corner + </label> + <fieldset v-if="!isFile" class="flex items-center gap-4 text-sm"> <legend class="sr-only">Colours</legend> <label class="flex items-center gap-1.5"> diff --git a/src/domain/__tests__/renderSvg.spec.js b/src/domain/__tests__/renderSvg.spec.js index cfcc491..1712f08 100644 --- a/src/domain/__tests__/renderSvg.spec.js +++ b/src/domain/__tests__/renderSvg.spec.js @@ -102,6 +102,14 @@ describe('sizes', () => { expect(svg).toContain('marker-start="url(#arrow)" marker-end="url(#arrow)"') }) + it('adds a linked Made with isketch mark only when asked', () => { + const document = sampleById('support').document + expect(renderSvg(document)).not.toContain('Made with isketch') + expect(renderSvg(document, { credit: true })).toMatch( + /<a href="https:\/\/isketch\.online"><text [^>]*text-anchor="end">Made with isketch<\/text><\/a>\n<\/svg>/, + ) + }) + it('draws a pen stroke as a line, with no outline or text', () => { const svg = renderSvg({ version: 3, diff --git a/src/domain/renderSvg.js b/src/domain/renderSvg.js index 5cac11c..58a9faa 100644 --- a/src/domain/renderSvg.js +++ b/src/domain/renderSvg.js @@ -61,14 +61,15 @@ const GLYPH = 0.56 * lays them out. * * @param {import('./types.js').FlowDocument} document - * @param {{ theme?: 'light' | 'dark', padding?: number, highlight?: Map<string, 'added' | 'removed' | 'changed'>, sketchFont?: string }} [options] + * @param {{ theme?: 'light' | 'dark', padding?: number, highlight?: Map<string, 'added' | 'removed' | 'changed'>, sketchFont?: string, credit?: boolean }} [options] * `highlight` marks nodes and edges by id, for a diff; `sketchFont` is the handwriting - * font as a data URL, embedded in a sketch so it looks the same wherever it opens + * font as a data URL, embedded in a sketch so it looks the same wherever it opens; + * `credit` adds a small "Made with isketch" in the bottom right corner, linked * @returns {string} */ export function renderSvg( document, - { theme = 'light', padding = 32, highlight = new Map(), sketchFont = '' } = {}, + { theme = 'light', padding = 32, highlight = new Map(), sketchFont = '', credit = false } = {}, ) { const colours = SVG_THEMES[theme] ?? SVG_THEMES.light const nodes = document.nodes.map(normaliseNode) @@ -136,6 +137,10 @@ export function renderSvg( sketch, ), ), + // In the padding, clear of every shape. + credit + ? `<a href="https://isketch.online"><text x="${round(left + width - 10)}" y="${round(top + height - 10)}" font-size="11" fill="${colours.muted}" text-anchor="end">Made with isketch</text></a>` + : '', '</svg>', ]