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
51 changes: 51 additions & 0 deletions .github/workflows/pages.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
name: pages

# Builds the @openhop/web bundle in fragment mode (no API server, flows in
# the URL hash) and deploys to GitHub Pages. Repo Settings → Pages must be
# set to "Source: GitHub Actions" for the deploy to succeed.

on:
push:
branches: [master]
workflow_dispatch:

permissions:
contents: read
pages: write
id-token: write

# Only one concurrent deploy; cancel any in-progress run when a newer push
# lands so we don't ship a stale bundle on top of a fresh one.
concurrency:
group: pages
cancel-in-progress: true

jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
- uses: actions/setup-node@v6
with:
node-version: 22
cache: npm
- run: npm ci
- name: Build web (fragment mode)
run: npm run build -w @openhop/web
env:
VITE_BASE: /OpenHop/
VITE_FRAGMENT_MODE: "1"
- uses: actions/configure-pages@v5
- uses: actions/upload-pages-artifact@v3
with:
path: packages/web/dist

deploy:
needs: build
runs-on: ubuntu-latest
environment:
name: github-pages
url: ${{ steps.deployment.outputs.page_url }}
steps:
- id: deployment
uses: actions/deploy-pages@v4
21 changes: 21 additions & 0 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

63 changes: 63 additions & 0 deletions packages/web/__tests__/share-url.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
import { describe, expect, it } from 'vitest'
import { parseFlowYaml } from '@openhop/shared'
import { buildShareUrl, decodeFragment, encodeFragment } from '../src/lib/share-url'

const SAMPLE_YAML = `meta:
title: Round-trip
path: demos
flow:
nodes:
- id: a
label: A
type: actor
- id: b
label: B
type: endpoint
steps:
- from: a
to: b
data: req
`

describe('share-url encode/decode', () => {
it('round-trips a typical flow', () => {
const enc = encodeFragment(SAMPLE_YAML)
expect(enc).not.toContain(' ')
expect(enc).not.toContain('\n')
const dec = decodeFragment(enc)
expect(dec).toBe(SAMPLE_YAML)
// And the round-tripped YAML still validates against the schema.
expect(parseFlowYaml(dec ?? '').success).toBe(true)
})

it('compresses well — typical flow encodes to fewer than the raw byte count', () => {
// Locks in that the lz-string variant is actually denser than the raw
// bytes for representative input. (URL-safe base64 of raw text would be
// ~1.34× — lz-string typically wins by ~3× on YAML's repetitive shape.)
const enc = encodeFragment(SAMPLE_YAML)
expect(enc.length).toBeLessThan(SAMPLE_YAML.length)
})

it('decodes empty / missing fragment to null', () => {
expect(decodeFragment('')).toBeNull()
})

it('decodes garbage to null (caller renders the corrupted-link banner)', () => {
expect(decodeFragment('this-is-not-lz-encoded')).toBeNull()
expect(decodeFragment('!!!~~~')).toBeNull()
})

it('buildShareUrl uses Vite BASE_URL so dev (/) and Pages (/OpenHop/) both work', () => {
const dev = buildShareUrl(SAMPLE_YAML, 'http://localhost:8788', '/')
expect(dev).toMatch(/^http:\/\/localhost:8788\/#[A-Za-z0-9_+\-$.]+$/)

const pages = buildShareUrl(SAMPLE_YAML, 'https://naorsabag.github.io', '/OpenHop/')
expect(pages).toMatch(/^https:\/\/naorsabag\.github\.io\/OpenHop\/#[A-Za-z0-9_+\-$.]+$/)

// Hash content matches encodeFragment for both URLs (proves the BASE_URL
// only affects the path, never the encoded payload).
const expectedHash = encodeFragment(SAMPLE_YAML)
expect(dev.endsWith(`#${expectedHash}`)).toBe(true)
expect(pages.endsWith(`#${expectedHash}`)).toBe(true)
})
})
2 changes: 2 additions & 0 deletions packages/web/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@
"@eslint/js": "^9.39.4",
"@openhop/shared": "*",
"@tailwindcss/vite": "^4.2.4",
"@types/lz-string": "^1.5.0",
"@types/node": "^24.12.2",
"@types/react": "^19.2.14",
"@types/react-dom": "^19.2.3",
Expand All @@ -44,6 +45,7 @@
"eslint-plugin-react-hooks": "^7.1.1",
"eslint-plugin-react-refresh": "^0.5.2",
"globals": "^17.6.0",
"lz-string": "^1.5.0",
"react": "^19.2.4",
"react-dom": "^19.2.4",
"tailwindcss": "^4.2.2",
Expand Down
17 changes: 13 additions & 4 deletions packages/web/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -300,10 +300,19 @@ function App() {
isInSubFlowRef.current = isInSubFlow
}, [isInSubFlow])
const handleCycleComplete = useCallback(() => {
if (!playingRef.current || !isInSubFlowRef.current) return
setTimeout(() => {
setFlowStack((prev) => (prev.length > 1 ? prev.slice(0, -1) : prev))
}, 800)
if (!playingRef.current) return
if (isInSubFlowRef.current) {
// Sub-flow finished — pop back to the parent (which is still playing,
// resumes from `resumeFromStep`).
setTimeout(() => {
setFlowStack((prev) => (prev.length > 1 ? prev.slice(0, -1) : prev))
}, 800)
} else {
// Root flow's cycle finished — stop playing so the header button flips
// back to "▶ Play". Otherwise the animation just loops indefinitely
// and the button is stuck on "⏸ Pause".
setPlaying(false)
}
}, [])

// Zoom transition when flow stack changes
Expand Down
Loading
Loading