diff --git a/CHANGELOG.md b/CHANGELOG.md index d0f0f38..3bce743 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added + +- Local app (`packages/web/src/App.tsx`): "Share" button in the header that copies a self-contained share URL of the currently-loaded flow to the clipboard. The URL points at the public Pages playground (`https://naorsabag.github.io/openhop/#`), reusing the same v1 fragment format the playground already decodes — so a link copied from `npm run dev` opens cleanly for anyone offsite without needing a local server. New `buildPagesShareUrl` helper in `lib/share-url.ts` centralizes the destination so future renames stay in one place. + ## [0.3.2] - 2026-05-15 ### Added diff --git a/README.md b/README.md index b00568c..455b2ee 100644 --- a/README.md +++ b/README.md @@ -93,6 +93,8 @@ Flows are authored in **compact YAML**, not JSON, so the payload the agent emits OpenHop is local-first — no hosted backend, no flow storage. To share a flow, open the [playground](https://naorsabag.github.io/openhop/), paste your YAML and hit **Save**: the page compresses the flow into the URL hash and copies a self-contained link to your clipboard. Nothing is uploaded — URL fragments stay in the browser. +Running the local app? The header's **Share** button does the same thing for the flow you have open — it builds a playground URL of the form `https://naorsabag.github.io/openhop/#` and copies it to your clipboard, so recipients can view your flow without installing OpenHop. + For flows too large to fit in a URL, share the YAML file directly. ## Install Options diff --git a/packages/web/__tests__/share-url.test.ts b/packages/web/__tests__/share-url.test.ts index 5d6ad2c..eba7384 100644 --- a/packages/web/__tests__/share-url.test.ts +++ b/packages/web/__tests__/share-url.test.ts @@ -3,7 +3,14 @@ import LZString from 'lz-string' import YAML from 'yaml' import { deflateSync } from 'fflate' import { parseFlowYaml } from '@openhop/shared' -import { buildShareUrl, decodeFragment, encodeFragment } from '../src/lib/share-url' +import { + buildPagesShareUrl, + buildShareUrl, + decodeFragment, + encodeFragment, + PAGES_SHARE_BASE, + PAGES_SHARE_ORIGIN, +} from '../src/lib/share-url' function toBase64Url(bytes: Uint8Array): string { let bin = '' @@ -104,4 +111,12 @@ describe('share-url encode/decode', () => { expect(dev.endsWith(`#${expectedHash}`)).toBe(true) expect(pages.endsWith(`#${expectedHash}`)).toBe(true) }) + + it('buildPagesShareUrl always points at the Pages playground (used by the local app)', () => { + const url = buildPagesShareUrl(SAMPLE_YAML) + expect(url.startsWith(`${PAGES_SHARE_ORIGIN}${PAGES_SHARE_BASE}#`)).toBe(true) + // Decodes back to the same flow regardless of which host produced the link. + const hash = url.split('#')[1] + expect(YAML.parse(decodeFragment(hash) ?? '')).toEqual(YAML.parse(SAMPLE_YAML)) + }) }) diff --git a/packages/web/src/App.tsx b/packages/web/src/App.tsx index f3aa829..e7afe03 100644 --- a/packages/web/src/App.tsx +++ b/packages/web/src/App.tsx @@ -8,6 +8,7 @@ const FlowEditorModal = lazy(() => import('./components/FlowEditorModal').then((m) => ({ default: m.FlowEditorModal })) ) import { buildStarterYaml } from './lib/starter-yaml' +import { buildPagesShareUrl } from './lib/share-url' import { isMobileViewport } from './lib/mobile' import { useFlowList, useFlowData } from './hooks/useFlowPolling' import { useFlowMutations } from './hooks/useFlowMutations' @@ -20,6 +21,23 @@ interface FlowNavItem { resumeFromStep?: number // step index to resume from when returning to this level } +// Android-style share glyph: three nodes connected by two edges (one +// anchor on the left, two on the right). Content runs edge-to-edge in +// the viewBox so the flex gap matches "Share"'s left padding. `display: +// block` overrides SVG's default baseline alignment, which otherwise +// drops the icon below the pixel-font's optical midline. +function ShareIcon() { + return ( + + ) +} + function App() { // Read flow ID from URL path: /flow/{id} const [selectedFlowId, setSelectedFlowId] = useState(() => { @@ -172,6 +190,38 @@ function App() { setEditor({ mode: 'closed' }) }, []) + // Toast for share-link feedback. Auto-clears via the timer ref so a + // rapid second click resets the countdown instead of stacking. + const [toast, setToast] = useState(null) + const toastTimerRef = useRef(null) + const showToast = useCallback((msg: string) => { + setToast(msg) + if (toastTimerRef.current !== null) window.clearTimeout(toastTimerRef.current) + toastTimerRef.current = window.setTimeout(() => setToast(null), 2400) + }, []) + + // Share URL points at the Pages playground (the only host that can + // decode it without a local server). Shares the top-level flow — sub-flow + // drilldown state is recipient-side navigation, not part of the data. + const apiFlowRef = useRef(null) + useEffect(() => { + apiFlowRef.current = apiFlow + }, [apiFlow]) + const handleShareFlow = useCallback(async () => { + const flow = apiFlowRef.current + if (!flow) return + const yamlText = YAML.stringify({ meta: flow.meta, flow: flow.flow }) + const url = buildPagesShareUrl(yamlText) + try { + await navigator.clipboard.writeText(url) + showToast('Copied playground share URL to clipboard.') + } catch { + // Some browsers / iframe contexts block clipboard. Surface the URL + // via prompt so the user can copy it by hand. + window.prompt('Copy this share URL:', url) + } + }, [showToast]) + const [playing, setPlaying] = useState(false) const [flowStack, setFlowStack] = useState([]) @@ -388,6 +438,21 @@ function App() { OpenHop +
+ {apiFlow && ( + + )} +
{/* Inspector toggle moved to a bookmark tab on the canvas's right edge. */} @@ -560,6 +625,23 @@ function App() { /> )} + + {toast && ( +
+ {toast} +
+ )} ) } diff --git a/packages/web/src/lib/share-url.ts b/packages/web/src/lib/share-url.ts index 43ca1dd..835a23d 100644 --- a/packages/web/src/lib/share-url.ts +++ b/packages/web/src/lib/share-url.ts @@ -114,3 +114,13 @@ export function buildShareUrl(yamlText: string, origin: string, baseUrl: string) // baseUrl ends with '/' (Vite convention), so concat without normalization. return `${origin}${baseUrl}#${fragment}` } + +// Public GitHub Pages playground — the only host that can decode share +// fragments without a local server. The local app uses this as the share +// target so URLs copied from `npm run dev` still work for anyone offsite. +export const PAGES_SHARE_ORIGIN = 'https://naorsabag.github.io' +export const PAGES_SHARE_BASE = '/openhop/' + +export function buildPagesShareUrl(yamlText: string): string { + return buildShareUrl(yamlText, PAGES_SHARE_ORIGIN, PAGES_SHARE_BASE) +}