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 @@ -71,6 +71,9 @@ nobody hosts it yet: run it yourself, or hand over the `.flow` file or the brief
and Eraser (`7`/`E`, click a shape or connection to delete it). Escape goes back to Select.
- **New diagram and samples.** Start empty, or from a web app architecture or support flow
sample. Undo brings back whatever was there.
- **Installable.** Install isketch from the browser as an app that opens with no connection. The
installed app opens `.flow` files from the file manager, and takes text shared to it from other
apps (`.flow` text or a Mermaid flowchart) as a new diagram, undoable.
- **Phones and tablets.** Below 768px the tool bar docks at the bottom and panels and dialogs rise
as sheets. Pinch to zoom and pan with two fingers anywhere, even over shapes; a long press (or a
right click) opens a shape's or connection's menu. A stylus draws with its pressure, and a palm
Expand Down
42 changes: 37 additions & 5 deletions backlog/tasks/fl-82 - Installable.md
Original file line number Diff line number Diff line change
@@ -1,9 +1,11 @@
---
id: FL-82
title: Installable
status: To Do
assignee: []
status: Done
assignee:
- '@raj-khan'
created_date: '2026-09-25 17:39'
updated_date: '2026-09-27 13:17'
labels:
- frontend
- pwa
Expand All @@ -26,8 +28,38 @@ Make isketch a PWA so it installs, works offline, and opens .flow files from the

<!-- AC:BEGIN -->

- [ ] #1 Installable, with the app shell cached for offline use
- [ ] #2 Opens .flow files from the OS via file handlers
- [ ] #3 Accepts shared text as a new diagram via a share target
- [x] #1 Installable, with the app shell cached for offline use
- [x] #2 Opens .flow files from the OS via file handlers
- [x] #3 Accepts shared text as a new diagram via a share target

<!-- AC:END -->

## Implementation Plan

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

1. Manifest: start_url /new, file_handlers for .flow, a GET share_target at /new with title, text, url.
2. A hand-rolled service worker written by a Vite plugin at build, listing the hashed bundle and public shell files: navigations network first with the cached app or landing as fallback, same-origin assets cache first, cross-origin untouched. Registered in production only.
3. useLaunchFiles: launchQueue consumer opens a launched .flow file through the same path as Open (undoable, remembers the handle).
4. useSharedText: /new?text=... opens shared .flow text (or Mermaid) as an undoable change and clears the query.
5. e2e: service worker caches the shell and /new loads offline; ?text= opens a shared diagram; a simulated launchQueue opens a file.

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

## Implementation Notes

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

Service worker is hand rolled (src/pwa/serviceWorker.js), written by a post-enforced Vite plugin so the HTML pages are in the bundle it lists. Cache lookups ignore Vary: a module import sends Origin and the install did not, so without it the lazy drawer chunk missed the cache offline.

launch_handler focus-existing was left out: it would hand a share to an open window through the launch queue instead of navigating to ?text=. Playwright blocks service workers except in the offline spec, so a cached build never outlives a rebuild in tests. nginx serves /sw.js no-cache.

Verified: e2e/installable.spec.js (6): offline reload of /new and a deep node route with the worker, CDP Page.getInstallabilityErrors [] and no manifest errors, manifest file_handlers and share_target, ?text= opens (undoable) and non-diagrams are refused, a stubbed launchQueue opens a .flow file. Unit tests for readSharedText and the worker source. e2e 125, vitest 254, lint and typecheck.
<!-- SECTION:NOTES:END -->

## Final Summary

<!-- SECTION:FINAL_SUMMARY:BEGIN -->

isketch installs as a PWA that opens offline (a build-generated service worker caches the shell), opens .flow files from the system through file handlers, and takes shared .flow or Mermaid text as a new, undoable diagram through a share target. Verified with Chrome installability checks, offline e2e and launch/share e2e.
<!-- SECTION:FINAL_SUMMARY:END -->
5 changes: 5 additions & 0 deletions docker/nginx.conf
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,11 @@ server {
add_header Cache-Control "public, immutable";
}

# The service worker checks for a new build on every visit.
location = /sw.js {
add_header Cache-Control "no-cache";
}

# Holds the build's analytics setting, under a name that never changes.
location = /analytics.js {
add_header Cache-Control "no-cache";
Expand Down
93 changes: 93 additions & 0 deletions e2e/installable.spec.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
import { expect, test } from '@playwright/test'

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

test.describe('offline', () => {
test.use({ serviceWorkers: 'allow' })

test('the service worker caches the app, which then opens with no connection', async ({
page,
context,
}) => {
await page.goto('/new')
await expect(shapes(page)).toHaveCount(5)
await page.evaluate(() => navigator.serviceWorker.ready)
// Controlled from the next load on; that load also fills the cache.
await page.reload()
await expect
.poll(() => page.evaluate(() => Boolean(navigator.serviceWorker.controller)))
.toBe(true)

await context.setOffline(true)
await page.reload()
await expect(shapes(page)).toHaveCount(5)
await page.goto('/new/node/b6a0c1')
await expect(page.getByLabel('Title')).toHaveValue('Away Message')
})

test('Chrome finds it installable, with a manifest it has no complaint about', async ({
page,
context,
}) => {
await page.goto('/new')
await page.evaluate(() => navigator.serviceWorker.ready)
const cdp = await context.newCDPSession(page)
expect((await cdp.send('Page.getInstallabilityErrors')).installabilityErrors).toEqual([])
expect((await cdp.send('Page.getAppManifest')).errors).toEqual([])
})
})

test('the manifest makes it installable, opening .flow files and taking shared text', async ({
request,
}) => {
const manifest = await (await request.get('/manifest.webmanifest')).json()
expect(manifest).toMatchObject({ start_url: '/new', display: 'standalone' })
expect(manifest.file_handlers[0].accept['text/plain']).toContain('.flow')
expect(manifest.share_target).toMatchObject({ action: '/new', method: 'GET' })
expect(manifest.icons.some((icon) => icon.sizes === '512x512')).toBe(true)
})

test('text shared to the app opens as a diagram, and undo brings the old one back', async ({
page,
}) => {
const text = 'pay = process "Pay"\nship = process "Ship"\npay -> ship'
await page.goto(`/new?title=Checkout&text=${encodeURIComponent(text)}`)
await expect(shapes(page)).toHaveCount(2)
await expect(page).toHaveURL(/\/new$/)
await expect(page.getByText('Opened what was shared.')).toBeVisible()

await page.getByRole('button', { name: 'Undo' }).last().click()
await expect(shapes(page)).toHaveCount(5)
})

test('shared text that is not a diagram says so, and changes nothing', async ({ page }) => {
await page.goto('/new?text=Look%20at%20this')
await expect(page.getByText('What was shared is not a diagram isketch can read.')).toBeVisible()
await expect(shapes(page)).toHaveCount(5)
})

test('a .flow file opened with the app from the system opens as a diagram', async ({ page }) => {
// The system's launch queue, as Chrome gives an installed app a file it opens.
await page.addInitScript(() => {
// Chrome has its own, which a plain assignment would not replace.
Object.defineProperty(window, 'launchQueue', {
configurable: true,
value: { setConsumer: (consumer) => (window.__launch = consumer) },
})
})
await page.goto('/new')
await expect(shapes(page)).toHaveCount(5)

await page.evaluate(() =>
window.__launch({
files: [
{
getFile: async () =>
new File(['title: Launched\na = process "A"\nb = process "B"'], 'launched.flow'),
},
],
}),
)
await expect(shapes(page)).toHaveCount(2)
await expect(page.getByText('Opened launched.flow')).toBeVisible()
})
2 changes: 2 additions & 0 deletions playwright.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,8 @@ export default defineConfig({
reporter: process.env.CI ? [['github'], ['html', { open: 'never' }]] : [['list']],

use: {
// The offline spec turns it on; elsewhere a cached build would outlive a rebuild.
serviceWorkers: 'block',
baseURL: 'http://localhost:4173',
trace: 'on-first-retry',
},
Expand Down
37 changes: 33 additions & 4 deletions public/manifest.webmanifest
Original file line number Diff line number Diff line change
Expand Up @@ -2,25 +2,54 @@
"name": "isketch — sketch it, hand it to your agent",
"short_name": "isketch",
"description": "A sketchpad for architecture and UI diagrams whose output is agent-ready: every sketch is also .flow text a coding agent reads exactly, and can edit back.",
"start_url": "/",
"start_url": "/new",
"scope": "/",
"display": "standalone",
"background_color": "#0d1117",
"theme_color": "#0d1117",
"icons": [
{ "src": "/icon-192.png", "sizes": "192x192", "type": "image/png", "purpose": "any" },
{
"src": "/icon-192.png",
"sizes": "192x192",
"type": "image/png",
"purpose": "any"
},
{
"src": "/icon-192-maskable.png",
"sizes": "192x192",
"type": "image/png",
"purpose": "maskable"
},
{ "src": "/icon-512.png", "sizes": "512x512", "type": "image/png", "purpose": "any" },
{
"src": "/icon-512.png",
"sizes": "512x512",
"type": "image/png",
"purpose": "any"
},
{
"src": "/icon-512-maskable.png",
"sizes": "512x512",
"type": "image/png",
"purpose": "maskable"
}
]
],
"id": "/new",
"file_handlers": [
{
"action": "/new",
"accept": {
"text/plain": [".flow"]
}
}
],
"share_target": {
"action": "/new",
"method": "GET",
"enctype": "application/x-www-form-urlencoded",
"params": {
"title": "title",
"text": "text",
"url": "url"
}
}
}
2 changes: 1 addition & 1 deletion src/composables/useDiagramFile.js
Original file line number Diff line number Diff line change
Expand Up @@ -136,7 +136,7 @@ export function useDiagramFile({ bindKeys = false } = {}) {
onBeforeUnmount(() => window.removeEventListener('keydown', onKeydown, true))
}

return { open, save, fileName: () => file.name }
return { open, save, openText: load, fileName: () => file.name }
}

/** @param {any} handle @param {string} text */
Expand Down
64 changes: 64 additions & 0 deletions src/composables/useLaunch.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
import { watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'

import { useDiagramFile } from '@/composables/useDiagramFile.js'
import { useFlowHistory } from '@/composables/useFlowHistory.js'
import { useFlowQuery } from '@/composables/useFlowQuery.js'
import { useReplaceDocument } from '@/composables/useNodeMutations.js'
import { readSharedText } from '@/domain/sharedText.js'
import { ROUTE } from '@/router/index.js'
import { useCanvasStore } from '@/stores/canvas.js'
import { useFileStore } from '@/stores/file.js'
import { useToastStore } from '@/stores/toasts.js'

/**
* How the installed app is opened by the system: a `.flow` file opened with
* isketch (file handlers), or text shared to it (the share target sends it
* as `?text=`). Either opens as an undoable change, once the diagram saved
* here has loaded, so undo can bring that one back.
*/
export function useLaunch() {
const route = useRoute()
const router = useRouter()
const { isLoading } = useFlowQuery()
const { openText } = useDiagramFile()
const replace = useReplaceDocument('Open shared text')
const { undo } = useFlowHistory()
const canvas = useCanvasStore()
const file = useFileStore()
const toasts = useToastStore()

const launchQueue = /** @type {any} */ (window).launchQueue
launchQueue?.setConsumer(async (/** @type {{ files?: any[] }} */ params) => {
const [handle] = params.files ?? []
if (!handle) return
const opened = await handle.getFile()
openText(await opened.text(), opened.name, handle)
})

watch(
[isLoading, () => route.query],
([loading, query]) => {
const shared = ['text', 'title', 'url'].map((key) => String(query[key] ?? ''))
if (loading || !shared.some(Boolean)) return

const [text, title, url] = shared
router.replace({ name: ROUTE.FLOW, query: {} })
const document = readSharedText({ text, title, url })
if (!document) {
toasts.push('What was shared is not a diagram isketch can read.', { tone: 'danger' })
return
}

canvas.forgetViewport()
file.forget()
replace.mutate(document, {
onSuccess: () =>
toasts.push('Opened what was shared. Undo brings yours back.', {
action: { label: 'Undo', run: undo },
}),
})
},
{ immediate: true },
)
}
32 changes: 32 additions & 0 deletions src/domain/__tests__/sharedText.spec.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
import { describe, expect, it } from 'vitest'

import { readSharedText } from '../sharedText.js'

describe('readSharedText', () => {
it('reads .flow text', () => {
const document = readSharedText({
text: 'title: Pay\npay = process "Pay"\nship = process "Ship"\npay -> ship',
})
expect(document.title).toBe('Pay')
expect(document.nodes.map((node) => node.id)).toEqual(['pay', 'ship'])
expect(document.edges).toHaveLength(1)
})

it('reads a Mermaid flowchart, named after the shared title', () => {
const document = readSharedText({
text: 'flowchart TD\n A[Start] --> B[Ship]',
title: 'Release',
})
expect(document.title).toBe('Release')
expect(document.nodes).toHaveLength(2)
})

it('finds the diagram when an app puts it in the url field', () => {
expect(readSharedText({ url: 'a = process "A"' })?.nodes).toHaveLength(1)
})

it('refuses text that is not a diagram', () => {
expect(readSharedText({ text: 'Look at this!' })).toBeNull()
expect(readSharedText({})).toBeNull()
})
})
22 changes: 22 additions & 0 deletions src/domain/sharedText.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
import { parseFlow } from './flowText.js'
import { fromMermaid } from './mermaid.js'

/**
* Text another app shared to isketch, as a diagram: `.flow` text first, then
* a Mermaid flowchart. Null when it is neither.
*
* @param {{ text?: string, title?: string, url?: string }} shared
* @returns {import('./types.js').FlowDocument | null}
*/
export function readSharedText({ text = '', title = '', url = '' }) {
// Apps disagree about where the words go; some put everything in `url`.
const body = [text, url].filter(Boolean).join('\n').trim()
if (!body) return null

const asFlow = parseFlow(body).document
const found = asFlow?.nodes.length ? asFlow : fromMermaid(body).document
if (!found?.nodes.length) return null

const named = title.trim()
return named && !/^title:/m.test(body) ? { ...found, title: named } : found
}
9 changes: 9 additions & 0 deletions src/main.js
Original file line number Diff line number Diff line change
Expand Up @@ -23,3 +23,12 @@ createApp(App)
.use(router)
.use(VueQueryPlugin, { queryClientConfig })
.mount('#app')

// Offline and installable: the build writes /sw.js; development has none.
if (import.meta.env.PROD && 'serviceWorker' in navigator) {
window.addEventListener('load', () => {
navigator.serviceWorker.register('/sw.js').catch(() => {
// No worker, no offline: the app itself works the same.
})
})
}
Loading
Loading