From 17de38f35e930c48da3048e3f5ee3861762be8a7 Mon Sep 17 00:00:00 2001 From: Naor Sabag <32329815+naorsabag@users.noreply.github.com> Date: Sun, 17 May 2026 07:03:06 +0000 Subject: [PATCH 1/2] [bot-tag-7f3a] feat(web): share button in local app header MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The local app had no first-class way to send someone the flow you're looking at — only the Pages playground did, by encoding the YAML into the URL hash on Save. That mismatch meant every "look at this" turned into a YAML paste-and-instruction. This wires the same affordance into the local app: - Header gets a "Share" button (only when a flow is loaded). Clicks YAML-stringify the in-memory flow, encode it via the existing v1 fragment format, build a URL pointing at the public Pages playground (https://naorsabag.github.io/openhop/#), and copy that URL to the clipboard with a cyan toast confirmation. Falls back to window.prompt if clipboard access is blocked. - New buildPagesShareUrl helper in lib/share-url.ts hardcodes the Pages destination as a module-level constant, so the local-app share target stays in one place if the playground ever moves. The generic buildShareUrl is unchanged — AppFragment.tsx still uses Vite's BASE_URL like before. The Pages site is the only host that can decode share fragments without a local server, so sharing always targets it (regardless of where the sharer is running OpenHop). Sub-flow drilldown state is recipient-side navigation and intentionally omitted from the payload — share is per-top-level-flow, matching AppFragment's Save behaviour. Inline SVG share icon (Android-style three connected nodes) sits in the button instead of a glyph, with display:block + edge-to-edge content so it lines up with the pixel font without padding asymmetry. Co-Authored-By: Claude Opus 4.7 (1M context) --- CHANGELOG.md | 4 ++ README.md | 2 + packages/web/__tests__/share-url.test.ts | 17 ++++- packages/web/src/App.tsx | 89 ++++++++++++++++++++++++ packages/web/src/lib/share-url.ts | 10 +++ 5 files changed, 121 insertions(+), 1 deletion(-) 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..81156ba 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,30 @@ 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 +197,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 +445,21 @@ function App() { OpenHop +
+ {apiFlow && ( + + )} +
{/* Inspector toggle moved to a bookmark tab on the canvas's right edge. */} @@ -560,6 +632,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) +} From dcf2cfaffa1451761ec4b6a798d164fbfaa5948d Mon Sep 17 00:00:00 2001 From: Naor Sabag <32329815+naorsabag@users.noreply.github.com> Date: Sun, 17 May 2026 07:07:46 +0000 Subject: [PATCH 2/2] [bot-tag-7f3a] chore(format): prettier-collapse ShareIcon SVG props onto one line CI's npm run format:check rejected the multi-line svg props in packages/web/src/App.tsx. Run prettier --write to match repo style; no behaviour change. Co-Authored-By: Claude Opus 4.7 (1M context) --- packages/web/src/App.tsx | 9 +-------- 1 file changed, 1 insertion(+), 8 deletions(-) diff --git a/packages/web/src/App.tsx b/packages/web/src/App.tsx index 81156ba..e7afe03 100644 --- a/packages/web/src/App.tsx +++ b/packages/web/src/App.tsx @@ -28,13 +28,7 @@ interface FlowNavItem { // drops the icon below the pixel-font's optical midline. function ShareIcon() { return ( -