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
4 changes: 2 additions & 2 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ A Manifest V3 Chrome extension (TypeScript + React 18 + Vite + Tailwind). Two HT
**Three surfaces:**

- The toolbar icon has **no popup**: `src/background.ts`'s `chrome.action.onClicked` handler opens the editor in a new tab with `?tabId=&windowId=&autocapture=1` of the active tab, and the editor auto-captures once on load (one-click capture).
- `src/editor/main.tsx` — the heart of the app (~1000 lines). Drives capture, hosts the annotation canvas, the comment timeline, general feedback, and the two output actions.
- `src/editor/main.tsx` — the heart of the app (~1000 lines). Drives capture, hosts the annotation canvas, the comment timeline, general feedback, and the three output actions.
- `src/viewer/` — renders a saved share from `?share=<id>` (local-only page).
- `src/background.ts` — service worker; currently just an install log. `src/content.ts` — injected on `<all_urls>`; responds to `SB_GET_PAGE_METRICS` / `SB_SCROLL_TO` / `SB_RESTORE_SCROLL` messages.

Expand All @@ -43,7 +43,7 @@ A Manifest V3 Chrome extension (TypeScript + React 18 + Vite + Tailwind). Two HT

**Design system (`src/components/ui/*` + `src/styles/globals.css` + `tailwind.config.js`):** components are driven by semantic HSL **CSS-variable tokens** (`--primary`, `--secondary`, `--muted`, `--accent`, `--destructive` + `-hover`, `--border`, `--input`, `--ring`) mapped to Tailwind color utilities — use `bg-primary`/`border-input`/`ring-ring` etc., never hardcoded `emerald-*`/`slate-*` literals, in the primitives. A `.dark` token block exists (opt-in via `class="dark"`; light is the default) and is kept **outside `@layer base`** so Tailwind does not tree-shake the unreferenced selector. `Select` is a **custom WAI-ARIA listbox** (not a native `<select>`): pass `value` + `onValueChange` + `options`, not `<option>` children — the native option popup is unstylable, which is why it was replaced.

**Two outputs from the editor:** (1) _Copy Local Share Link_ → `saveLocalShare` + viewer URL; (2) _Prepare for Cloud LLM_ → downloads the annotated PNG and copies `buildExternalLlmPrompt` output to the clipboard. The extension makes **no network requests of its own**; data leaves the device only via that explicit manual export.
**Three outputs from the editor:** (1) _Copy Local Share Link_ → `saveLocalShare` + viewer URL; (2) _Prepare for Cloud LLM_ → downloads the annotated PNG and copies `buildExternalLlmPrompt` output to the clipboard; (3) _Copy for Claude Code_ → saves the PNG to `Downloads/shotback/` via `chrome.downloads`, reads back its absolute path, and copies `buildClaudeCodePrompt` output with the path translated by `toClaudePath` (`src/lib/wslPath.ts`, `C:\… → /mnt/c/…`) so a WSL Claude Code session can read the file directly. The extension makes **no network requests of its own**; data leaves the device only via these explicit manual exports.

## Conventions

Expand Down
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,7 @@ npm run build
4. **Use** one of the outputs:
- **Copy Local Share Link** for local profile review
- **Prepare for Cloud LLM** for external LLMs (prompt + image download)
- **Copy for Claude Code** saves the PNG to `Downloads/shotback/` and copies a prompt that points to the file by path (a Windows path is translated to its WSL `/mnt/c/...` equivalent), so a Claude Code session can read it directly

## 📁 Project Structure

Expand Down
19 changes: 11 additions & 8 deletions SECURITY.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,13 +15,14 @@ Security-sensitive areas:
Shotback requests only what full-page capture requires. Each permission and its
justification:

| Permission | Why it is needed |
| ------------------------------ | --------------------------------------------------------------------------------------------------------------------- |
| `activeTab` | Access the tab the user is viewing when they invoke capture. |
| `tabs` | Coordinate capture: query the active tab, focus the target tab, and open the editor/viewer. Uses tab/window ids only. |
| `scripting` | Inject the capture helper that measures the page and drives scroll-and-stitch. |
| `storage` + `unlimitedStorage` | Persist share metadata in `chrome.storage.local` and large annotated images in IndexedDB without quota errors. |
| `host_permissions: <all_urls>` | A general screenshot tool must capture whatever page the user is on; there is no fixed allowlist of sites. |
| Permission | Why it is needed |
| ------------------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `activeTab` | Access the tab the user is viewing when they invoke capture. |
| `tabs` | Coordinate capture: query the active tab, focus the target tab, and open the editor/viewer. Uses tab/window ids only. |
| `scripting` | Inject the capture helper that measures the page and drives scroll-and-stitch. |
| `storage` + `unlimitedStorage` | Persist share metadata in `chrome.storage.local` and large annotated images in IndexedDB without quota errors. |
| `downloads` | Save the annotated PNG to `Downloads/shotback/` and read back its on-disk path for the "Copy for Claude Code" handoff. Writes only files the user explicitly exports; reads only the path of the file it just created. |
| `host_permissions: <all_urls>` | A general screenshot tool must capture whatever page the user is on; there is no fixed allowlist of sites. |

Access to page content is exercised **only at user-initiated capture time**, not
in the background.
Expand All @@ -42,7 +43,9 @@ to avoid running on every page load.
- Screenshots, annotations, and feedback stay in the local browser profile.
- The extension makes no network requests of its own.
- Data leaves the device only when the user explicitly uses the cloud LLM
fallback (manual image download + clipboard paste).
fallback (manual image download + clipboard paste). The "Copy for Claude Code"
action likewise only writes a PNG to `Downloads/shotback/` and copies a text
prompt to the clipboard — it makes no network request.

## Reporting a Vulnerability

Expand Down
2 changes: 1 addition & 1 deletion public/manifest.json
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
"name": "Shotback",
"description": "Capture full-page screenshots, annotate, and generate a local share link for LLM feedback.",
"version": "0.1.0",
"permissions": ["activeTab", "tabs", "scripting", "storage", "unlimitedStorage"],
"permissions": ["activeTab", "tabs", "scripting", "storage", "unlimitedStorage", "downloads"],
"host_permissions": ["<all_urls>"],
"icons": {
"16": "icons/icon16.png",
Expand Down
83 changes: 82 additions & 1 deletion src/editor/main.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -17,17 +17,36 @@ import {
type BoxResizeHandle
} from "@/lib/boxResize";
import { captureFullPage } from "@/lib/capture";
import { annotationSummary, buildExternalLlmPrompt } from "@/lib/feedback";
import { annotationSummary, buildClaudeCodePrompt, buildExternalLlmPrompt } from "@/lib/feedback";
import {
buildLocalShareUrl,
deleteLocalShare,
listLocalShares,
saveLocalShare,
type LocalShareMeta
} from "@/lib/localStore";
import { toClaudePath } from "@/lib/wslPath";
import type { Annotation, AnnotationTool, BoxAnnotation } from "@/types/annotation";
import "@/styles/globals.css";

const delay = (ms: number): Promise<void> => new Promise((resolve) => setTimeout(resolve, ms));

/**
* Resolve a completed download's absolute on-disk path. Polls because the path
* is only populated once Chrome finishes writing the file. Returns "" if the
* path cannot be resolved (interrupted, or still pending after the timeout).
*/
async function resolveDownloadPath(downloadId: number): Promise<string> {
for (let attempt = 0; attempt < 20; attempt++) {
const [item] = await chrome.downloads.search({ id: downloadId });
if (item?.state === "complete" && item.filename) return item.filename;
if (item?.state === "interrupted") return "";
await delay(150);
}
const [item] = await chrome.downloads.search({ id: downloadId });
return item?.state === "complete" ? (item.filename ?? "") : "";
}

interface DraftShape {
xStart: number;
yStart: number;
Expand Down Expand Up @@ -593,6 +612,61 @@ function EditorApp(): JSX.Element {
}
};

// Save the annotated image to Downloads/shotback and copy a Claude Code prompt
// that references the file by path — translating a Windows path to its WSL
// /mnt equivalent so a WSL session can read it directly. No network involved.
const copyForClaudeCode = async (): Promise<void> => {
if (!baseDataUrl) {
setStatus({ kind: "error", message: "Capture a screenshot before copying for Claude Code." });
return;
}

setIsBusy(true);
setStatus(null);
let objectUrl = "";

try {
const merged = await exportAnnotatedImage(baseDataUrl, annotations, { generalFeedback });
const blob = await (await fetch(merged)).blob();
objectUrl = URL.createObjectURL(blob);
const relativeName = `shotback/cap-${Date.now()}.png`;

const downloadId = await chrome.downloads.download({
url: objectUrl,
filename: relativeName,
conflictAction: "uniquify",
saveAs: false
});

const absolutePath = await resolveDownloadPath(downloadId);
const filePath = absolutePath ? toClaudePath(absolutePath) : `Downloads/${relativeName}`;
const prompt = buildClaudeCodePrompt({ filePath, pageUrl, generalFeedback, annotations });
await navigator.clipboard.writeText(prompt);

setStatus(
absolutePath
? {
kind: "success",
message:
"Copied a Claude Code prompt with the image's path. Paste it into your session."
}
: {
kind: "error",
message:
"Image saved to Downloads/shotback, but its full path could not be resolved. Copied a prompt with the relative path — fix it if your Claude session needs an absolute path."
}
);
} catch (error) {
setStatus({
kind: "error",
message: error instanceof Error ? error.message : "Failed to copy for Claude Code"
});
} finally {
if (objectUrl) URL.revokeObjectURL(objectUrl);
setIsBusy(false);
}
};

return (
<main className="grid min-h-screen grid-cols-1 gap-4 p-4 lg:grid-cols-[360px_1fr] lg:p-5">
<Card className="lg:max-h-[calc(100vh-2.5rem)] lg:overflow-auto">
Expand Down Expand Up @@ -697,6 +771,13 @@ function EditorApp(): JSX.Element {
>
Prepare for Cloud LLM
</Button>
<Button
variant="secondary"
disabled={!baseDataUrl || isBusy}
onClick={() => void copyForClaudeCode()}
>
Copy for Claude Code
</Button>
<Button
variant="default"
disabled={!baseDataUrl || isBusy}
Expand Down
49 changes: 38 additions & 11 deletions src/lib/feedback.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,16 +6,9 @@ export function annotationSummary(annotation: Annotation): string {
return annotation.comment?.trim() || "(no comment)";
}

/**
* Build the structured prompt handed to an external/cloud LLM alongside the
* downloaded annotated image. Kept pure so it can be unit tested.
*/
export function buildExternalLlmPrompt(params: {
pageUrl: string;
generalFeedback: string;
annotations: Annotation[];
}): string {
const comments = params.annotations
/** Numbered, tool-tagged list of area comments shared by the prompt builders. */
function formatAreaComments(annotations: Annotation[]): string {
const comments = annotations
.map((annotation, index) => {
if (annotation.tool === "text") {
return `${index + 1}. [text] ${annotation.text || "(empty)"}`;
Expand All @@ -25,13 +18,47 @@ export function buildExternalLlmPrompt(params: {
})
.join("\n");

return comments || "(none)";
}

/**
* Build the structured prompt handed to an external/cloud LLM alongside the
* downloaded annotated image. Kept pure so it can be unit tested.
*/
export function buildExternalLlmPrompt(params: {
pageUrl: string;
generalFeedback: string;
annotations: Annotation[];
}): string {
return [
"Please review this screenshot and provide feedback.",
"",
`Page URL: ${params.pageUrl || "(unknown)"}`,
`General feedback context: ${params.generalFeedback.trim() || "(none)"}`,
"",
"Area comments:",
comments || "(none)"
formatAreaComments(params.annotations)
].join("\n");
}

/**
* Build the prompt copied to the clipboard for a Claude Code session. Leads with
* the saved file's path so Claude can read the image directly from disk (e.g. a
* Windows Downloads path translated to its WSL `/mnt/...` equivalent).
*/
export function buildClaudeCodePrompt(params: {
filePath: string;
pageUrl: string;
generalFeedback: string;
annotations: Annotation[];
}): string {
return [
`Review this screenshot: ${params.filePath}`,
"",
`Page URL: ${params.pageUrl || "(unknown)"}`,
`General feedback context: ${params.generalFeedback.trim() || "(none)"}`,
"",
"Area comments:",
formatAreaComments(params.annotations)
].join("\n");
}
18 changes: 18 additions & 0 deletions src/lib/wslPath.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
/**
* Translate an absolute file path into one a WSL Claude Code session can read.
*
* A Windows drive path (`C:\Users\…` or `C:/Users/…`) becomes its default WSL
* automount equivalent (`/mnt/c/Users/…`): the drive letter is lowercased and
* all backslashes become forward slashes. Any path that is not a Windows drive
* path (already-POSIX, or unrecognized) is returned unchanged.
*
* Pure and dependency-free so it can be unit tested away from `chrome.*`.
*/
export function toClaudePath(absolutePath: string): string {
const match = /^([A-Za-z]):[\\/](.*)$/.exec(absolutePath);
if (!match) return absolutePath;

const drive = match[1].toLowerCase();
const rest = match[2].replace(/\\/g, "/");
return `/mnt/${drive}/${rest}`;
}
38 changes: 37 additions & 1 deletion tests/feedback.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,9 @@
import { describe, expect, it } from "vitest";
import { annotationSummary, buildExternalLlmPrompt } from "../src/lib/feedback";
import {
annotationSummary,
buildClaudeCodePrompt,
buildExternalLlmPrompt
} from "../src/lib/feedback";
import type { ArrowAnnotation, BoxAnnotation, TextAnnotation } from "../src/types/annotation";

const baseTimestamp = "2026-02-21T00:00:00.000Z";
Expand Down Expand Up @@ -97,3 +101,35 @@ describe("buildExternalLlmPrompt", () => {
expect(prompt).toContain("2. [text] (empty)");
});
});

describe("buildClaudeCodePrompt", () => {
it("leads with the saved file path", () => {
const prompt = buildClaudeCodePrompt({
filePath: "/mnt/c/Users/dcca/Downloads/shotback/cap.png",
pageUrl: "https://example.test/page",
generalFeedback: "looks off",
annotations: [box("fix padding")]
});

expect(
prompt.startsWith("Review this screenshot: /mnt/c/Users/dcca/Downloads/shotback/cap.png")
).toBe(true);
expect(prompt).toContain("Page URL: https://example.test/page");
expect(prompt).toContain("General feedback context: looks off");
expect(prompt).toContain("1. [box] fix padding");
});

it("uses placeholders when context is empty", () => {
const prompt = buildClaudeCodePrompt({
filePath: "Downloads/shotback/cap.png",
pageUrl: "",
generalFeedback: "",
annotations: []
});

expect(prompt).toContain("Review this screenshot: Downloads/shotback/cap.png");
expect(prompt).toContain("Page URL: (unknown)");
expect(prompt).toContain("General feedback context: (none)");
expect(prompt).toContain("Area comments:\n(none)");
});
});
32 changes: 32 additions & 0 deletions tests/wslPath.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
import { describe, expect, it } from "vitest";
import { toClaudePath } from "../src/lib/wslPath";

describe("toClaudePath", () => {
it("translates a Windows drive path to its /mnt mount", () => {
expect(toClaudePath("C:\\Users\\dcca\\Downloads\\shotback\\cap.png")).toBe(
"/mnt/c/Users/dcca/Downloads/shotback/cap.png"
);
});

it("lowercases the drive letter", () => {
expect(toClaudePath("D:\\work\\img.png")).toBe("/mnt/d/work/img.png");
});

it("handles forward-slash Windows paths", () => {
expect(toClaudePath("C:/Users/dcca/x.png")).toBe("/mnt/c/Users/dcca/x.png");
});

it("normalizes mixed separators", () => {
expect(toClaudePath("E:\\a/b\\c.png")).toBe("/mnt/e/a/b/c.png");
});

it("passes through POSIX paths unchanged", () => {
expect(toClaudePath("/home/dcca/Downloads/shotback/cap.png")).toBe(
"/home/dcca/Downloads/shotback/cap.png"
);
});

it("leaves non-drive strings unchanged", () => {
expect(toClaudePath("shotback/cap.png")).toBe("shotback/cap.png");
});
});
Loading