diff --git a/.github/workflows/deploy-web.yml b/.github/workflows/deploy-web.yml
new file mode 100644
index 0000000..dcee214
--- /dev/null
+++ b/.github/workflows/deploy-web.yml
@@ -0,0 +1,71 @@
+name: Deploy web UI to GitHub Pages
+
+on:
+ # Deploy on every push to main that touches the web package (or this workflow).
+ push:
+ branches: [main]
+ paths:
+ - "packages/web/**"
+ - "packages/core/**"
+ - ".github/workflows/deploy-web.yml"
+ - "pnpm-lock.yaml"
+ # Allow manual re-deploy from the Actions UI.
+ workflow_dispatch:
+
+# Least-privilege except for the Pages-specific permissions deploy-pages needs.
+permissions:
+ contents: read
+ pages: write
+ id-token: write
+
+# Only one Pages deployment can run at a time. If another commit lands while a
+# deploy is in flight, cancel the queued one and skip ahead — never queue
+# multiple concurrent deploys for the same env.
+concurrency:
+ group: pages
+ cancel-in-progress: true
+
+jobs:
+ build:
+ runs-on: ubuntu-latest
+ timeout-minutes: 10
+ steps:
+ - uses: actions/checkout@v4
+
+ - name: Setup pnpm
+ uses: pnpm/action-setup@v4
+
+ - name: Setup Node.js
+ uses: actions/setup-node@v4
+ with:
+ node-version: 20
+ cache: pnpm
+
+ - name: Install dependencies
+ run: pnpm install --frozen-lockfile
+
+ - name: Build core
+ run: pnpm --filter @gitmarks/core build
+
+ - name: Build web
+ run: pnpm --filter @gitmarks/web build
+
+ - name: Configure GitHub Pages
+ uses: actions/configure-pages@v5
+
+ - name: Upload Pages artifact
+ uses: actions/upload-pages-artifact@v3
+ with:
+ path: packages/web/dist
+
+ deploy:
+ needs: build
+ runs-on: ubuntu-latest
+ timeout-minutes: 5
+ environment:
+ name: github-pages
+ url: ${{ steps.deployment.outputs.page_url }}
+ steps:
+ - name: Deploy to GitHub Pages
+ id: deployment
+ uses: actions/deploy-pages@v4
diff --git a/CLAUDE.md b/CLAUDE.md
index 2d42484..4b2f644 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -6,12 +6,12 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co
Five packages are merged to main and working:
- `@gitmarks/core` (`packages/core/`) — schemas, GitHub Contents API client with optimistic concurrency, ULID/URL helpers (incl. opt-in tracking-param stripping), pure mutation helpers, example fixtures. 77 unit tests.
-- `@gitmarks/extension-shared` (`packages/extension-shared/`) — canonical owner of the cross-browser extension code: popup, options, background, all of `src/lib/`, and the chrome/browser stub. 100 unit tests live here. Consumed by both browser shells via `workspace:*`. Uses `browser.*` via `webextension-polyfill`.
+- `@gitmarks/extension-shared` (`packages/extension-shared/`) — canonical owner of the cross-browser extension code: popup, options, background, all of `src/lib/`, and the chrome/browser stub. 104 unit tests live here. Consumed by both browser shells via `workspace:*`. Uses `browser.*` via `webextension-polyfill`.
- `@gitmarks/extension-chrome` (`packages/extension-chrome/`) — Chrome MV3 shell. Manifest + Vite/crxjs build + Playwright e2e (4 passing, 2 skipped — see issue history for the activeTab/Playwright limitation). Source files are thin entries that re-export from `extension-shared` via its `exports` map.
- `@gitmarks/extension-firefox` (`packages/extension-firefox/`) — Firefox MV3 shell. Manifest + plain Vite build + manual smoke test (Playwright Firefox doesn't reliably drive WebExtensions). Targets Firefox 121+ for MV3 SW parity. Load via `about:debugging` → "Load Temporary Add-on".
- `@gitmarks/web` (`packages/web/`) — Vite + React + Tailwind SPA. List, search, tag management, bulk operations, trash, Netscape HTML export. Talks directly to GitHub via `@gitmarks/core`. Hash routing (`#/setup`, `#/`, `#/tags`, `#/trash`). 109 unit + component tests.
-Total: 286 unit + component tests across the monorepo, plus 6 Playwright e2e (4 passing, 2 skipped) in the Chrome shell.
+Total: 290 unit + component tests across the monorepo, plus 6 Playwright e2e (4 passing, 2 skipped) in the Chrome shell. The web UI is auto-deployed to GitHub Pages by `.github/workflows/deploy-web.yml` on every push to `main` that touches `packages/web/**` or `packages/core/**`.
Pending packages (in dependency order): Safari.
@@ -32,10 +32,10 @@ These are spec-level constraints; don't violate without an explicit discussion:
- **IDs are ULIDs generated client-side.** Native browser node IDs are not stable across reinstalls — the extension maintains a `{ulid: chrome_node_id}` map in `chrome.storage.local`, rebuilt by URL match.
- **Folder ↔ string path:** `Bookmarks Bar` ↔ `""` (root), `Other Bookmarks` ↔ `"_other"`, nested folders joined with `/`. Folder structure is derived from bookmarks, not stored separately.
- **Loop suppression:** when applying a remote change to `chrome.bookmarks`, register the URL in an in-memory TTL map for ~2s so our own listener doesn't echo it back to GitHub.
-- **URL safety:** Bookmark URLs are checked against an allowlist of safe schemes (`isSafeBookmarkUrl` in `@gitmarks/core`) at three points: (a) save time in the extension's `buildBookmark` factory, (b) render time in the web UI's `BookmarkRow`, and (c) the extension's `apply-remote` boundary that writes remote entries into the native bookmark tree. Unsafe schemes (`javascript:`, `data:`, etc.) are rejected/skipped.
+- **URL safety:** Bookmark URLs are checked against an allowlist of safe schemes (`isSafeBookmarkUrl` in `@gitmarks/core`) at every write or render boundary: (a) popup save in `buildBookmark` (throws); (b) the SW listener path in `applyBatch` create + update branches (skip + warn); (c) reconcile's `remoteByUrl` construction (filters unsafe remote entries before they reach the local tree or the idMap); (d) reconcile's `localOnly` upload loop (filters unsafe local entries before pushing to GitHub); (e) the `apply-remote` boundary that writes remote entries into `browser.bookmarks` (skip + warn); (f) the web UI's `BookmarkRow` render (turns unsafe URLs into non-clickable italic spans with a tooltip). Unsafe schemes (`javascript:`, `data:`, etc.) are rejected/skipped everywhere bookmarks cross a trust boundary.
- **Remote file validation:** `useGitmarksData` re-validates `bookmarks.json` and `tags.json` through Zod (`bookmarksFileSchema` / `tagsFileSchema`) after reading from GitHub. Malformed remote data surfaces as an error rather than rendering attacker-controlled fields.
- **Tag color guard:** `TagChip` regex-validates the color string before placing it into a CSS `style` object; malformed colors fall back to a default.
-- **CSP:** Web UI's `index.html` and both extension manifests carry an explicit Content-Security-Policy restricting `connect-src` to `https://api.github.com`, disallowing inline scripts, framing, and form actions.
+- **CSP:** Both extension manifests carry an explicit `content_security_policy.extension_pages` restricting `connect-src` to `'self' https://api.github.com`, disallowing inline scripts. The web UI's `` CSP is injected by a Vite plugin scoped to `apply: "build"` only (running it in dev would block Vite's HMR WebSocket); `frame-ancestors` is intentionally omitted because `` cannot enforce it per CSP3 — clickjacking defense must come from an HTTP header at the hosting layer.
## Architecture by package
diff --git a/README.md b/README.md
index aadab32..5672325 100644
--- a/README.md
+++ b/README.md
@@ -26,7 +26,7 @@ SPA. Safari is next in the roadmap. See `spec.md` for the full design.
on the next 5-minute poll
- Concurrent edits from multiple devices reconcile automatically via
GitHub's file SHA + optimistic retry-replay
-- 286 automated unit + component tests + 6 Playwright e2e (against real Chromium)
+- 290 automated unit + component tests + 6 Playwright e2e (against real Chromium)
- Optional **tracking-param stripping** (utm_*, fbclid, gclid, etc.) at save time — opt-in via settings
## Packages
@@ -34,11 +34,20 @@ SPA. Safari is next in the roadmap. See `spec.md` for the full design.
| Package | Role |
|---|---|
| `@gitmarks/core` | Shared TypeScript library: schemas (Zod), GitHub Contents API client with optimistic concurrency, ULID + URL helpers, pure mutation helpers |
-| `@gitmarks/extension-shared` | Cross-browser extension source — popup, options, background, lib/ helpers. Consumed by both browser shells via `workspace:*`. 100 unit tests live here. |
+| `@gitmarks/extension-shared` | Cross-browser extension source — popup, options, background, lib/ helpers. Consumed by both browser shells via `workspace:*`. 104 unit tests live here. |
| `@gitmarks/extension-chrome` | Chrome MV3 shell. Manifest + Vite/crxjs build + Playwright e2e. Thin entry files import from `extension-shared`. |
| `@gitmarks/extension-firefox` | Firefox MV3 shell. Manifest + plain Vite build. Same source as Chrome via `extension-shared`. Load via `about:debugging`. |
| `@gitmarks/web` | Static SPA — list, search, tag management, bulk operations, trash, Netscape HTML export, sign out. Vite + React + Tailwind. Talks directly to GitHub via `@gitmarks/core`. Deploys to GitHub Pages or Cloudflare Pages. |
+## Try the web UI
+
+The read-side web UI is auto-deployed to GitHub Pages:
+**https://paperhurts.github.io/gitmarks/**
+
+You'll need a fine-grained PAT (see "Your data, your PAT" below) and your
+own private bookmarks repo. The web UI runs entirely in your browser — no
+server sees your token.
+
## Quick start (Chrome extension)
```bash