diff --git a/.gitignore b/.gitignore
index aa06506..0c1cfb2 100644
--- a/.gitignore
+++ b/.gitignore
@@ -2,6 +2,8 @@ node_modules/
build/
dist/
coverage/
+test-results/
+playwright-report/
.claude/settings.local.json
.DS_Store
*.log
diff --git a/CLAUDE.md b/CLAUDE.md
index 8167af4..58a4d7a 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -70,6 +70,24 @@ Monaco. Roadmap lives in `DEVELOPMENT_PLAN.md` — keep its checkboxes current.
hardcoded color, font-size or radius in `components/styles/` — add a token
rather than a literal, or `/* token-exempt: reason */` when a literal is
genuinely right. New UI must be checked in both themes.
+- **Alignment.** Any full-width horizontal strip (toolbar, file-slots row,
+ section header, dialog header) is a *band*: it carries `.band` and vertically
+ centres its content with flexbox. Never fake vertical alignment with top
+ padding — it drifts the instant a font size or line-height changes (that is
+ what twice broke the sidebar/file-input alignment). Bands that sit at the
+ same vertical position across the sidebar/main divider share a height so
+ their content lines up by construction: the file-slots row and EVERY sidebar
+ section header add `.band-row` (height `--band-row`), so the headers all
+ match each other and the first lines up with the file inputs. Controls
+ sharing a band row must be
+ equal-height flex-centred boxes.
+- **Icons are SVG, never Unicode glyphs.** A symbol like ◈ / ⧉ / 🔒 tofus into a
+ `[]` box on any font that lacks it, which kept happening. Every icon comes
+ from ``, whose geometry lives in `src/renderer/src/icons.js`
+ (Feather/Lucide-style 24×24, sizes to 1em, inherits `currentColor`). Add a new
+ icon by adding an entry to that map — never reach for a glyph character. Text
+ glyphs used as *prose* (⌘ in a shortcut label, ↔ in a diff name, the − on the
+ deletions count) stay text; only standalone/interactive icons are SVG.
- Every modal is a `BaseDialog` (backdrop, header, `#actions` slot, Escape,
focus trap). Its panel is BaseDialog's, so a dialog sizes itself with the
`width` prop — scoped CSS cannot reach into a child. `:escape-closes="false"`
@@ -98,9 +116,19 @@ Monaco. Roadmap lives in `DEVELOPMENT_PLAN.md` — keep its checkboxes current.
in the Docker env.
- Vitest, jsdom environment (`tests/setup.js` provides localStorage — Node's
built-in one is broken in workers). The tree under `tests/` mirrors `src/`:
- `tests/main/`, `tests/renderer/{stores,utils,adapters}/`. A new test goes
- in the directory matching its subject's source path. Fixtures live in
- `tests/data/`.
+ `tests/main/`, `tests/renderer/{stores,utils,adapters,composables}/`. A new
+ test goes in the directory matching its subject's source path. Fixtures live
+ in `tests/data/`.
+- **Interaction bugs split two ways, and each has a home — this is how the
+ recurring UI regressions get caught.** *Event logic* (does a backdrop click
+ close only when the press began on the backdrop? does Space commit a tag?
+ does Escape leave the snippet editor open?) is pulled OUT of the `.vue` file
+ into a `composables/` unit and unit-tested there — never left inline where
+ nothing exercises it. The Mermaid-viewer resize-closes bug became
+ `useBackdropClose` + `useBackdropClose.test.js`; follow that pattern for any
+ new event guard. *Layout* (alignment, sizing, overlap) can't be asserted in
+ jsdom — verify it in the Docker env with screenshots, and encode the
+ invariant as a shared class/token (see the band system) so it can't drift.
- Every behavior change in `src/main/sealing.js`, `vaultCrypt.js`, the
Pinia stores, or the adapters needs a test in the same change. Crypto
code additionally needs negative tests (tamper, wrong key, expiry).
@@ -110,6 +138,17 @@ Monaco. Roadmap lives in `DEVELOPMENT_PLAN.md` — keep its checkboxes current.
- UI-level changes are verified in the Docker test env (screenshots via
`xdotool`/`scrot` inside the container; keyboard via `xdotool`, not the
noVNC page — see `docker/README.md`).
+- **End-to-end** tests live in `e2e/` (Playwright driving the app's OWN
+ Electron via `_electron` — no bundled browser, no network). `make e2e`
+ builds then runs them INSIDE the up container (they need Xvfb :99, so they
+ can't use the one-off `make check`/`test` container). Each test launches its
+ own Electron against a throwaway `--user-data-dir`, so runs never touch the
+ developer's real data and never fight the single-instance lock. Reach for E2E
+ for a flow only a real launch exercises (preload/IPC round-trips, persistence
+ across relaunch, OS-clipboard writes) — the kind of bug jsdom can't see. E2E
+ is trusted-click, so `navigator.clipboard` writes there hit the deny-all
+ permission handler and fail: clipboard writes go through `window.api.copyText`
+ (main process, `src/main/clipboard.js`), never `navigator.clipboard`.
## Workflow
@@ -128,5 +167,10 @@ Monaco. Roadmap lives in `DEVELOPMENT_PLAN.md` — keep its checkboxes current.
work around npm dropping them from the lock (they are transitive
optionals of vitest's wasm toolchain) — do not remove them just because
nothing imports them.
+- Playwright drives the app's own Electron, never a bundled browser, so its
+ ~400 MB browser download is skipped via `PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD=1`
+ (set in `docker/Dockerfile` before `npm ci`, and pass it when adding/updating
+ deps). Never run `playwright install` — it would pull Chromium/Firefox/WebKit
+ the suite doesn't use.
- Update `README.md` (including the mermaid diagram) and
`DEVELOPMENT_PLAN.md` when architecture or feature status changes.
diff --git a/DEVELOPMENT_PLAN.md b/DEVELOPMENT_PLAN.md
index 288d56b..560dc3b 100644
--- a/DEVELOPMENT_PLAN.md
+++ b/DEVELOPMENT_PLAN.md
@@ -105,9 +105,24 @@ First run: `npm install && npm run dev`
adapters); ESLint flat config with security rules (renderer banned
from Node/Electron imports, no v-html/eval) + Prettier;
`npm run check` = lint + tests; CLAUDE.md encodes the guidelines.
-- [x] Light/dark theme toggle (Monaco `vs` / `vs-dark` + CSS variables on
- `:root[data-theme]`; toolbar button + View menu + Ctrl/Cmd+D; choice
- persisted through the durable data-dir store, so it survives a reinstall)
+- [x] End-to-end suite in `e2e/` (Playwright `_electron`, `make e2e`): drives
+ the built app in an isolated `--user-data-dir`, no bundled browser / no
+ network. Covers launch smoke, the Settings domain-pane rail, theme apply +
+ persistence across a relaunch, the snippet copy "Copied" flash + the real
+ OS-clipboard write, paste-compare diffing with Monaco stats, saving a diff
+ and reopening it after a relaunch (the full vault encrypt→store→decrypt
+ round-trip), the Share two-step flow into first-time key setup, and Mermaid
+ rendering in both the diagram viewer and the snippet editor's live preview.
+ Caught a real shipping bug — `navigator.clipboard.writeText` is denied by
+ the deny-all permission handler, so all clipboard writes now go through the
+ main process (`src/main/clipboard.js`, `window.api.copyText`).
+- [x] Five selectable themes (Light default, Dark, Solar, Neon, Contrast) —
+ registry in `utils/themes.js`, palette per theme on `:root[data-theme]`,
+ Monaco/Mermaid ground keyed off `isDarkTheme`; picked in Settings →
+ Appearance (swatch previews), Ctrl/Cmd+D still flips light↔dark; choice
+ persisted through the durable data-dir store, so it survives a reinstall
+- [x] Settings split into domain panes behind a left rail (Appearance /
+ Storage / Limits) so the window stays scannable
- [x] Re-read files on window focus (quiet re-read: no large-file prompt,
silent skip if the file vanished; toast when the diff was reloaded)
- [x] Window state persistence (size/position/maximized in
@@ -122,7 +137,8 @@ First run: `npm install && npm run dev`
- [x] Encrypted, tagged snippet library ("quiet shelves" sidebar: ★ Favorites +
All snippets, newest-first, collapsible tag filter that composes with
- search, hover preview that decrypts on demand)
+ search, hover preview that decrypts on demand; copy-to-clipboard shows a
+ transient "Copied" flash at the row via unit-tested `useCopyFeedback`)
- [x] Mermaid diagram rendering for `mermaid` snippets — lazy-loaded (dynamic
`import`, its own build chunks; nothing added to the main bundle), runs
**offline under the strict CSP with no `unsafe-eval`** (verified against
@@ -132,6 +148,38 @@ First run: `npm install && npm run dev`
- [x] Live preview in the snippet editor + a resizable, zoom/pan diagram viewer;
diagram theme paired to the app theme (dark → `dark`, light → `default`),
re-rendered on theme switch so text never blends into the canvas
+- [x] Auto-detect for the snippet editor's syntax picker
+ (`utils/detectLanguage.js`): distinctive, low-ambiguity signals for every
+ offered language (JSON, Mermaid, SQL, Markdown, YAML/K8s, Python, shell,
+ PHP, JS, TS, XML, HTML, CSS, Dockerfile, Go, Rust, Java), ordered
+ most-distinctive-first with anti-false-positive guards (a fenced block is
+ Markdown not its inner code; TS before JS; HTML-only tags before generic
+ XML; code braces disqualify CSS/YAML). Best-effort — a miss lands on
+ plaintext rather than mis-coloring. Covered by a positive-plus-negative
+ test matrix in `tests/renderer/utils/detectLanguage.test.js`.
+
+## Phase 2.6 – UI/UX refinements ✅
+
+- [x] Plaintext `settings.json` store (`stores/settingsStore.js`): reorderable
+ sidebar sections, drag-reorderable saved-diff categories, shortcut-bar
+ visibility, and user-raisable comparison-file / snippet size limits with
+ safe defaults and hard ceilings (main enforces the file limit from it)
+- [x] Reorderable sidebar sections behind a shared `SectionHeader`; Saved /
+ External / Snippets each extracted into a self-contained component
+- [x] Diff search gains match-case, whole-word, and safety-limited regex
+ (`utils/searchRegex.js` refuses over-long / catastrophic patterns)
+- [x] Partial paste mode: diff pasted text against a dropped/chosen file
+- [x] Tools menu grouped per format (Base64 / JSON / XML / SQL / Text
+ Encryption); Help → Keyboard Shortcuts lists bindings for the host OS
+- [x] Help → Report an Issue confirms before leaving the offline sandbox, then
+ hands the fixed repo issue URL to the OS browser (the only outward link;
+ the URL is fixed in main, the renderer can only trigger it)
+- [x] Categories are local-only — never offered or sent in the share flow
+- [x] Color palette split into `styles/themes.css` (structure stays in
+ `tokens.css`), plus app-wide `.section-actions` spacing so no section's
+ buttons drift out of alignment
+- [x] Mermaid "Expand" opens a full-window viewer above all dialogs (the
+ snippet editor closes first)
## Phase 3 – Packaging & distribution (~1–2 days + cert wait times)
diff --git a/Makefile b/Makefile
index e426c5c..601078e 100644
--- a/Makefile
+++ b/Makefile
@@ -13,7 +13,7 @@ VNC_URL := http://localhost:6080/vnc.html
RUN_NPM := docker compose run --rm --entrypoint npm $(SERVICE)
.PHONY: help install test-env test-env-detached up stop down restart rebuild logs shell \
- clean dev check test lint build package-win package-linux package-mac audit-fix brew-cask
+ clean dev check test e2e lint build package-win package-linux package-mac audit-fix brew-cask
help: ## List available targets
@grep -E '^[a-zA-Z_-]+:.*?## .*$$' $(MAKEFILE_LIST) | \
@@ -83,6 +83,12 @@ check: ## Lint + tests in the container (run before declaring a task done)
test: ## Run the test suite in the container
$(RUN_NPM) -- test
+# Unlike `check`/`test`, E2E needs the virtual display, so it runs INSIDE the
+# up container (which owns Xvfb :99) rather than a one-off `run` container.
+# `up` is a dependency so the display is guaranteed to be there first.
+e2e: up ## Build + drive the app end-to-end with Playwright in the running container
+ docker compose exec -T $(SERVICE) npm run test:e2e
+
lint: ## Run ESLint in the container
$(RUN_NPM) -- run lint
@@ -124,10 +130,6 @@ package-mac: ## Build the macOS DMG -> dist/ (must run natively on macOS)
exit 1; }
npm run build:mac
-# Regenerate the Homebrew cask (packaging/homebrew/diff-bro.rb) with the current
-# version and the sha256 of the released DMG. Runs on the host (plain node, no
-# deps). By default it downloads the tagged release asset; pass a local DMG with
-# DMG=dist/Diff-Bro-macOS.dmg to hash that instead. Override the version with
-# VERSION=x.y.z. CI does this automatically on release (see release.yml).
+# Regenerate the Homebrew cask (packaging/homebrew/diff-bro.rb) with the current tag version
brew-cask: ## Regenerate the Homebrew cask for the current (or VERSION=x.y.z) release
node scripts/gen-homebrew-cask.mjs $(VERSION) $(if $(DMG),--dmg $(DMG),)
diff --git a/README.md b/README.md
index 92630e1..86820a2 100644
--- a/README.md
+++ b/README.md
@@ -59,11 +59,19 @@ Builds are **unsigned** for now (no Apple/Microsoft cert yet):
- **Diff** two files or pasted text — split/inline, word-level highlights,
syntax highlighting, live re-diff when a file changes on disk, and an in-view
- search (plain / regex, match count, jump-to-match).
+ search with match-case, whole-word, and safety-limited regex, match count and
+ jump-to-match.
+- **Paste mode** compares two pasted snippets, or mixes the two — paste one
+ side and drop/choose a real file on the other (partial paste).
- **Drag & drop** files onto the window (two at once builds the diff; a third
- starts over). Fixed, single window; light/dark themes; clamped zoom.
+ starts over). Fixed, single window; five themes (Light, Dark, Solar, Neon,
+ Contrast) picked in Settings → Appearance; clamped zoom.
- **Saved diffs** — AES-256-GCM encrypted at rest, auto-expiring (≤ 24 h),
- organized into categories, favoritable.
+ organized into drag-reorderable categories, favoritable. Categories are a
+ local organizing tool and never travel with a shared diff.
+- **Rearrangeable sidebar** — reorder the Saved / External / Snippets sections
+ to taste; the order (and other preferences) persist in a plaintext
+ `settings.json`.
- **Share** a saved diff as a sealed, signed `.diffbro` file for one recipient;
manage named trusted keys under the **Security** menu.
- **Snippets** — an encrypted, tagged, non-expiring text library with
@@ -71,8 +79,12 @@ Builds are **unsigned** for now (no Apple/Microsoft cert yet):
filter + copy, and passphrase-protected export/import. **Mermaid** snippets
render to a diagram — a live preview while editing plus a resizable
zoom/pan viewer, themed to match the app, all offline.
-- **Tools** — Base64, and JSON / XML / SQL format+validate (Monaco-highlighted,
- with "Add to Snippets"), plus a passphrase text Encrypt/Decrypt.
+- **Tools** — grouped by format (Base64, and JSON / XML / SQL format+validate,
+ Monaco-highlighted, with "Add to Snippets"), plus a passphrase text
+ Encrypt/Decrypt. **Help → Keyboard Shortcuts** lists every binding for your OS.
+- **Settings** — split into Appearance / Storage / Limits panes; backed by a
+ plaintext `settings.json` (theme, data folder, shortcut-bar visibility, and
+ user-raisable comparison-file / snippet size limits with safe defaults).
- **Config backup/restore** — one passphrase-encrypted file for your keys,
trusted hosts, snippets and settings (not diffs).
@@ -105,6 +117,11 @@ No local Node? The same flow runs in Docker: `make dev` (app via noVNC at
) and `make check`. See
[docker/README.md](docker/README.md) and `make help`.
+End-to-end tests (`make e2e`) drive the built app through Playwright's Electron
+integration in the Docker display env — launch smoke, Settings panes, theme
+persistence, snippet copy. No bundled browser and no network, in keeping with
+the offline guarantee.
+
## Docs
- [Architecture](docs/architecture.md) — processes, trust boundary, directory map.
diff --git a/docker/Dockerfile b/docker/Dockerfile
index ecea95c..c9a6663 100644
--- a/docker/Dockerfile
+++ b/docker/Dockerfile
@@ -34,6 +34,11 @@ WORKDIR /app
# by one fails `npm ci` under the other. One resolver everywhere.
RUN npm install -g npm@11
+# The E2E suite drives the app's own Electron (playwright's `_electron`), never
+# a bundled browser — so skip Playwright's ~400 MB Chromium/Firefox/WebKit
+# download at install time. Keeps the image lean and the build offline-light.
+ENV PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD=1
+
# Install dependencies at image-build time so the node_modules volume that
# shadows the host's (Windows) node_modules starts out populated with the
# Linux Electron binaries.
diff --git a/docs/architecture.md b/docs/architecture.md
index bc9a2a8..91d41ed 100644
--- a/docs/architecture.md
+++ b/docs/architecture.md
@@ -9,7 +9,7 @@ crypto, keys) lives in **main**; the **preload** exposes only a small, validated
flowchart TB
subgraph R["Renderer — Vue 3 (sandboxed: no Node, no Electron)"]
ui["UI DiffViewer · Saved diffs · Snippets · Tools dialogs"]
- stores["Pinia stores diff · vault · snippets"]
+ stores["Pinia stores diff · vault · snippets · settings"]
adapters["Adapter registry text → { kind, … } comparable"]
ui --> stores --> adapters
end
@@ -54,7 +54,9 @@ flowchart TB
- `src/main` — Electron main: window, menu, file dialogs + reads (binary/size
detection, `chardet`/`iconv-lite` encoding), `appData.js` (the configurable
data directory + file-backed store where diffs/snippets/keys live, so data
- survives a reinstall), and the pure, unit-tested crypto cores: `sealing.js`
+ survives a reinstall; it also reads the renderer's plaintext `settings.json`
+ for the few limits main enforces, like the large-file threshold), and the
+ pure, unit-tested crypto cores: `sealing.js`
(sealed diff sharing), `vaultCrypt.js` (saved-diff vault), `snippetSealing.js`
(snippet export), `textCrypt.js` (Tools encrypt/decrypt), `configBackup.js`
(config backup). `share.js` is the thin Electron glue.
diff --git a/e2e/category.spec.mjs b/e2e/category.spec.mjs
new file mode 100644
index 0000000..387b78d
--- /dev/null
+++ b/e2e/category.spec.mjs
@@ -0,0 +1,32 @@
+import { test, expect } from './fixtures.mjs'
+
+// Creating a category on the fly from the Save dialog is a UI seam the store
+// tests can't reach: the "+ New category…" option reveals a name field, and on
+// save the diff must file into a freshly created, auto-expanded shelf in the
+// sidebar. Uses paste-compare to make something saveable without a file dialog.
+test('saving into a new category creates its shelf and files the diff there', async ({ page }) => {
+ const CATEGORY = 'E2E work items'
+ const DIFF = 'Categorised diff'
+
+ await page.getByRole('button', { name: 'Paste text' }).click()
+ await page.getByPlaceholder('Paste original text here').fill('a')
+ await page.getByPlaceholder('Paste changed text here').fill('b')
+ await page.getByRole('button', { name: 'Compare', exact: true }).click()
+
+ await page.getByRole('button', { name: 'Save', exact: true }).click()
+ const dialog = page.getByRole('dialog', { name: 'Save diff' })
+ await dialog.getByLabel('Name', { exact: true }).fill(DIFF)
+
+ // Choosing "+ New category…" (value __new__) reveals the name field; fill it,
+ // then save. Selected by value — the option label carries an ellipsis, and the
+ // wrapping