diff --git a/marketing/cards/features.png b/marketing/cards/features.png
new file mode 100644
index 00000000..fda22e95
Binary files /dev/null and b/marketing/cards/features.png differ
diff --git a/marketing/cards/hero-og.png b/marketing/cards/hero-og.png
new file mode 100644
index 00000000..ce1f2f91
Binary files /dev/null and b/marketing/cards/hero-og.png differ
diff --git a/marketing/cards/privacy.png b/marketing/cards/privacy.png
new file mode 100644
index 00000000..68d6a282
Binary files /dev/null and b/marketing/cards/privacy.png differ
diff --git a/marketing/cards/src/card.css b/marketing/cards/src/card.css
new file mode 100644
index 00000000..c105d721
--- /dev/null
+++ b/marketing/cards/src/card.css
@@ -0,0 +1,113 @@
+/* Shared brand tokens for Git Diff Review social cards.
+ Colors/fonts mirror the app theme (dunkel.json + style.css). */
+
+@font-face {
+ font-family: "Fira Code";
+ font-weight: 400;
+ font-display: block;
+ src: url("../../../packages/ui/src/fonts/firacode/FiraCode-Regular.woff2") format("woff2");
+}
+@font-face {
+ font-family: "Fira Code";
+ font-weight: 600;
+ font-display: block;
+ src: url("../../../packages/ui/src/fonts/firacode/FiraCode-SemiBold.woff2") format("woff2");
+}
+
+:root {
+ --bg: #242424;
+ --bg-2: #1c1c1c;
+ --panel: #2c2c2c;
+ --fg: #fafafa;
+ --muted: #a0a0a0;
+ --faint: #6b6b6b;
+ --accent: #89ddff;
+ --green: #5ad27e;
+ --red: #ff6762;
+ --border: #ffffff14;
+ --radius: 18px;
+}
+
+* {
+ margin: 0;
+ padding: 0;
+ box-sizing: border-box;
+}
+
+html,
+body {
+ background: var(--bg);
+}
+
+body {
+ font-family: -apple-system, system-ui, "Segoe UI", Roboto, sans-serif;
+ color: var(--fg);
+ -webkit-font-smoothing: antialiased;
+ text-rendering: optimizeLegibility;
+ overflow: hidden;
+}
+
+.mono {
+ font-family: "Fira Code", ui-monospace, SFMono-Regular, Menlo, monospace;
+}
+
+/* Card frame: each card sets data-w / data-h via inline width/height. */
+.card {
+ position: relative;
+ overflow: hidden;
+ background:
+ radial-gradient(120% 120% at 100% 0%, #2f2f2f 0%, var(--bg) 55%) ,
+ var(--bg);
+}
+
+/* Subtle diff-gutter motif used on several cards. */
+.gutter {
+ position: absolute;
+ inset: 0;
+ pointer-events: none;
+ opacity: 0.5;
+}
+.gutter span {
+ position: absolute;
+ left: 0;
+ width: 6px;
+ border-radius: 0 4px 4px 0;
+}
+.add {
+ color: var(--green);
+}
+.del {
+ color: var(--red);
+}
+.cyan {
+ color: var(--accent);
+}
+.muted {
+ color: var(--muted);
+}
+
+.wordmark {
+ font-weight: 700;
+ letter-spacing: -0.02em;
+}
+
+.pill {
+ display: inline-flex;
+ align-items: center;
+ gap: 10px;
+ border: 1px solid var(--border);
+ background: #ffffff0a;
+ border-radius: 999px;
+ color: var(--muted);
+}
+
+.dots {
+ display: inline-flex;
+ gap: 8px;
+}
+.dots i {
+ width: 13px;
+ height: 13px;
+ border-radius: 50%;
+ display: inline-block;
+}
diff --git a/marketing/cards/src/features.html b/marketing/cards/src/features.html
new file mode 100644
index 00000000..723aa688
--- /dev/null
+++ b/marketing/cards/src/features.html
@@ -0,0 +1,136 @@
+
+
+
+
+
+
+
+
+
+
+

+
Git Diff Review
+
+
The pull-request review you already know — locally.
+
+
+
⌥
+
+
A real review surface
+
File list, status badges, +/− counts, jump between hunks.
+
+
+
+
±
+
+
Split or unified diffs
+
Syntax-highlighted, your choice per session.
+
+
+
+
✓
+
+
Viewed-file tracking & inline notes
+
Tick files off (0/14 viewed), drop comments on lines.
+
+
+
+
⌨
+
+
Keyboard-driven
+
j/k navigate · v viewed · c comment · ⌘↵ submit
+
+
+
+
⌂
+
+
100% local
+
No cloud, no sign-up, no telemetry — stored in local SQLite.
+
+
+
+
+
+
+
diff --git a/marketing/cards/src/hero-og.html b/marketing/cards/src/hero-og.html
new file mode 100644
index 00000000..871d927c
--- /dev/null
+++ b/marketing/cards/src/hero-og.html
@@ -0,0 +1,113 @@
+
+
+
+
+
+
+
+
+
+
+
+

+
● before you commit
+
+
Git Diff
Review
+
+ Review your local Git changes like a pull request.
+ On your machine. Nothing leaves it.
+
+
+ caught the stray debug log
+ before it hit history
+
+
+
+
+
+
diff --git a/marketing/cards/src/privacy.html b/marketing/cards/src/privacy.html
new file mode 100644
index 00000000..9318941f
--- /dev/null
+++ b/marketing/cards/src/privacy.html
@@ -0,0 +1,121 @@
+
+
+
+
+
+
+
+
+
+
+

+
Git Diff Review
+
+
Your code never leaves your machine.
+
+ No cloud
+ No sign-up
+ No telemetry
+
+
+
+
+
+
+
+
+ # reviews, comments & prefs live here — only here
+ ~/Library/Application Support/git-diff/reviews.sqlite3
+
+
+
+
+
+
diff --git a/marketing/cards/src/screenshot.html b/marketing/cards/src/screenshot.html
new file mode 100644
index 00000000..d4e98ba4
--- /dev/null
+++ b/marketing/cards/src/screenshot.html
@@ -0,0 +1,90 @@
+
+
+
+
+
+
+
+
+
+
+
A real PR review surface — for your local working tree.
+
+

+
Git Diff Review
+
+
+
+
+
+
+
+
+

+
+
+
+
diff --git a/packages/ui/src/composables/useAppSession.ts b/packages/ui/src/composables/useAppSession.ts
index 7caa4209..1e23b75a 100644
--- a/packages/ui/src/composables/useAppSession.ts
+++ b/packages/ui/src/composables/useAppSession.ts
@@ -45,9 +45,29 @@ export interface AppSessionOptions {
}
export function useAppSession(opts: AppSessionOptions) {
- let unsubscribeShortcuts: (() => void) | null = null;
let unsubscribeLaunchIntent: (() => void) | null = null;
+ // Attach keyboard shortcuts synchronously during setup so the listener is
+ // live regardless of auth-bootstrap outcome or timing. Wiring this after
+ // `await bootstrapAuth()` meant a throwing/hanging bootstrap left every
+ // shortcut dead. The composable registers its own onUnmounted cleanup.
+ useKeyboardShortcuts({
+ enabled: opts.shortcutsEnabled,
+ onSelectAdjacent: opts.selectAdjacent,
+ onJumpToHunk: opts.jumpToHunk,
+ onToggleViewed: () => {
+ const file = opts.selectedFile.value;
+
+ if (file && opts.changedByPath.value.has(file.path)) {
+ void opts.toggleViewed(file);
+ }
+ },
+ onStartReview: () => void opts.startReview(),
+ onOpenSearch: () => {
+ opts.searchOpen.value = true;
+ },
+ });
+
async function bootstrapAuth() {
const { entered } = await opts.bootstrapAuthStore();
@@ -129,22 +149,6 @@ export function useAppSession(opts: AppSessionOptions) {
onMounted(async () => {
await bootstrapAuth();
- unsubscribeShortcuts = useKeyboardShortcuts({
- enabled: opts.shortcutsEnabled,
- onSelectAdjacent: opts.selectAdjacent,
- onJumpToHunk: opts.jumpToHunk,
- onToggleViewed: () => {
- const file = opts.selectedFile.value;
-
- if (file && opts.changedByPath.value.has(file.path)) {
- void opts.toggleViewed(file);
- }
- },
- onStartReview: () => void opts.startReview(),
- onOpenSearch: () => {
- opts.searchOpen.value = true;
- },
- });
unsubscribeLaunchIntent = window.diffApp.onLaunchIntent(async (intent) => {
if (intent.kind === 'help' || !intent.repoPath) {
return;
@@ -164,7 +168,6 @@ export function useAppSession(opts: AppSessionOptions) {
});
onUnmounted(() => {
- unsubscribeShortcuts?.();
unsubscribeLaunchIntent?.();
});
diff --git a/packages/ui/src/composables/useKeyboardShortcuts.ts b/packages/ui/src/composables/useKeyboardShortcuts.ts
index 84fff605..b5650a30 100644
--- a/packages/ui/src/composables/useKeyboardShortcuts.ts
+++ b/packages/ui/src/composables/useKeyboardShortcuts.ts
@@ -89,6 +89,8 @@ export function useKeyboardShortcuts(opts: UseKeyboardShortcutsOptions): () => v
if ((bindings.fileFilter && matchesBinding(event, bindings.fileFilter)) || (bindings.diffSearch && matchesBinding(event, bindings.diffSearch))) {
event.preventDefault();
opts.onOpenSearch();
+
+ return;
}
}
diff --git a/packages/ui/tests/useKeyboardShortcuts.test.ts b/packages/ui/tests/useKeyboardShortcuts.test.ts
new file mode 100644
index 00000000..7bd80c73
--- /dev/null
+++ b/packages/ui/tests/useKeyboardShortcuts.test.ts
@@ -0,0 +1,96 @@
+import { mount } from '@vue/test-utils';
+import { createPinia } from 'pinia';
+import { ref, defineComponent, h, type Ref } from 'vue';
+import { beforeEach, describe, expect, it, vi } from 'vitest';
+import { useKeyboardShortcuts, type UseKeyboardShortcutsOptions } from '@composables/useKeyboardShortcuts.js';
+
+// Mounts a trivial host that wires the shortcuts in setup, mirroring how
+// useAppSession attaches them synchronously during component setup.
+function mountHost(overrides: Partial = {}, enabled: Ref = ref(true)) {
+ const opts: UseKeyboardShortcutsOptions = {
+ enabled,
+ onSelectAdjacent: vi.fn(),
+ onJumpToHunk: vi.fn(),
+ onToggleViewed: vi.fn(),
+ onStartReview: vi.fn(),
+ onOpenSearch: vi.fn(),
+ ...overrides,
+ };
+
+ const Host = defineComponent({
+ setup() {
+ useKeyboardShortcuts(opts);
+
+ return () => h('div');
+ },
+ });
+
+ const wrapper = mount(Host, { global: { plugins: [createPinia()] } });
+
+ return { wrapper, opts };
+}
+
+function press(key: string, init: KeyboardEventInit = {}): void {
+ document.dispatchEvent(new KeyboardEvent('keydown', { key, bubbles: true, cancelable: true, ...init }));
+}
+
+describe('useKeyboardShortcuts', () => {
+ beforeEach(() => {
+ document.body.innerHTML = '';
+ });
+
+ it('fires the navigation action for the default next_file binding', () => {
+ const { opts, wrapper } = mountHost();
+
+ press('j');
+
+ expect(opts.onSelectAdjacent).toHaveBeenCalledWith(1);
+
+ wrapper.unmount();
+ });
+
+ it('fires for ArrowDown / ArrowUp aliases', () => {
+ const { opts, wrapper } = mountHost();
+
+ press('ArrowDown');
+ press('ArrowUp');
+
+ expect(opts.onSelectAdjacent).toHaveBeenNthCalledWith(1, 1);
+ expect(opts.onSelectAdjacent).toHaveBeenNthCalledWith(2, -1);
+
+ wrapper.unmount();
+ });
+
+ it('does nothing when disabled', () => {
+ const enabled = ref(false);
+ const { opts, wrapper } = mountHost({}, enabled);
+
+ press('j');
+
+ expect(opts.onSelectAdjacent).not.toHaveBeenCalled();
+
+ wrapper.unmount();
+ });
+
+ it('ignores keystrokes typed into inputs', () => {
+ const { opts, wrapper } = mountHost();
+ const input = document.createElement('input');
+
+ document.body.appendChild(input);
+
+ input.dispatchEvent(new KeyboardEvent('keydown', { key: 'j', bubbles: true }));
+
+ expect(opts.onSelectAdjacent).not.toHaveBeenCalled();
+
+ wrapper.unmount();
+ });
+
+ it('detaches the listener on unmount', () => {
+ const { opts, wrapper } = mountHost();
+
+ wrapper.unmount();
+ press('j');
+
+ expect(opts.onSelectAdjacent).not.toHaveBeenCalled();
+ });
+});