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
49 changes: 49 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
# CLAUDE.md

This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.

## Required reading before edits

Read `FIREHOSE.md` before making any change — it is the project's operating contract (OpenSpec-lite: fluid, iterative, brownfield-first, one logical unit of work per change). If `FIREHOSE.md` conflicts with another local guideline, `FIREHOSE.md` wins unless the user overrides it. `AGENTS.md` and `CONTRIBUTING.md` restate the same workflow.

Non-trivial work is tracked as a change folder under `.docs/`: `proposal.md` (why/scope), `spec.md` (RFC-2119 requirements with Given/When/Then scenarios), `design.md` (how), `tasks.md` (checklist). Move `todo/ → doing/ → done/` and add a `completion-summary.md` when finishing. `.docs/PRD.md` is the product entry point. `.docs/` is source-of-truth — never gitignore it.

## Commands

- `npm run check` — the gate: `typecheck && lint && test && build`. Run before any PR.
- `npm run dev` / `npm run build` — Vite dev server / production build into `dist/`.
- `npm run test` — Vitest (`vitest run`). Single file: `npx vitest run tests/capture.test.ts`. Single test: `npx vitest run -t "name substring"`. Watch: `npx vitest`.
- `npm run typecheck`, `npm run lint` (`lint:fix`), `npm run format` (`format:check`).
- `npm run gen:icons` — regenerate `public/icons/*` from `scripts/generate-icons.mjs`.

To run the extension: `npm run build`, then load the `dist/` folder via `chrome://extensions` (Developer mode → Load unpacked). There is no test runner for the live extension — UI flows are verified manually (capture, annotate/comment, timeline select/remove, viewer, cloud-LLM fallback).

## Architecture

A Manifest V3 Chrome extension (TypeScript + React 18 + Vite + Tailwind). Three HTML entry points plus two extension scripts, all bundled by `vite.config.ts` (which fixes `background.js`/`content.js` output names so the manifest can reference them). `@/*` aliases `src/*`.

**Four surfaces:**
- `src/popup/` — toolbar popup; its only job is to open the editor in a new tab, passing `?tabId=&windowId=` of the active tab.
- `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/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.

**Full-page capture flow** (`src/lib/capture.ts`, called from the editor — *not* from the background): activates the target tab → `ensureInjectable` re-injects `content.js` → reads page metrics → `buildScrollSteps` computes viewport-sized scroll offsets → for each step, scrolls the page (via content-script message) and calls `chrome.tabs.captureVisibleTab` → stitches the PNG segments onto a single canvas scaled by `devicePixelRatio`. Restores scroll and the previously-active tab in `finally` blocks.

**Storage (two-tier, see `src/lib/localStore.ts` + `src/lib/shareDb.ts`):** share *metadata* (annotations, feedback, page URL, blob key) lives in `chrome.storage.local` under `share:<id>` keys; the large PNG *blob* lives in IndexedDB (`shotback`/`shareImages`) keyed by `share-image:<id>`. `localStore` is the only module that touches both; it converts dataURL↔Blob, enforces `schemaVersion: 2`, transparently migrates legacy v1 records (inline `imageDataUrl`) on read, and prunes via `DEFAULT_RETENTION_POLICY` (50 shares / 30 days) after each save. A share link is `chrome.runtime.getURL("viewer.html?share=<id>")` — intentionally profile-scoped, never a public URL.

**Pure, unit-tested helpers** (these are where the real logic and the tests live — `tests/*.test.ts` mirror them):
- `src/lib/annotate.ts` — `exportAnnotatedImage` rasterizes annotations onto the screenshot; `selectFeedbackRenderMode` picks footer vs. overlay so the export canvas never exceeds `MAX_EXPORT_CANVAS_HEIGHT`/`AREA` limits.
- `src/lib/feedback.ts` — `buildExternalLlmPrompt` (the structured prompt copied for the cloud-LLM fallback) and `annotationSummary`.
- `src/lib/boxResize.ts` — box drag/resize geometry.
- `src/types/annotation.ts` — the `Annotation` discriminated union (`box` | `arrow` | `text`).

**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.

## Conventions

- TypeScript `strict` with `noUnusedLocals`/`noUnusedParameters`; explicit over clever.
- `kebab-case` file names; small, low-blast-radius diffs over broad refactors.
- Keep pure logic in `src/lib/*` (testable, no `chrome.*`); confine `chrome.*` calls to the editor/popup/viewer/background/content boundaries.
- Conventional-commit style messages (`feat:`, `fix:`, `chore:`, `security:`); one logical change per commit.
- Permissions are deliberately minimal (`activeTab`, `tabs`, `scripting`, `storage`, `unlimitedStorage`, `<all_urls>` host access) — see `SECURITY.md` before touching `public/manifest.json`.
10 changes: 10 additions & 0 deletions package-lock.json

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

1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@
"check": "npm run typecheck && npm run lint && npm run test && npm run build"
},
"dependencies": {
"@fontsource-variable/manrope": "^5.2.8",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"react": "^18.3.1",
Expand Down
70 changes: 47 additions & 23 deletions src/editor/main.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -120,7 +120,7 @@ function EditorApp(): JSX.Element {
const [color, setColor] = useState("#ff3333");
const [generalFeedback, setGeneralFeedback] = useState("");
const [progress, setProgress] = useState<string>("");
const [status, setStatus] = useState<string>("");
const [status, setStatus] = useState<{ kind: "success" | "error"; message: string } | null>(null);
const [shareUrl, setShareUrl] = useState<string>("");
const [draft, setDraft] = useState<DraftShape | null>(null);
const [drag, setDrag] = useState<DragState | null>(null);
Expand Down Expand Up @@ -206,12 +206,15 @@ function EditorApp(): JSX.Element {

const takeScreenshot = async (): Promise<void> => {
if (!canCapture) {
setStatus("Missing target tab information. Open this page from the extension popup.");
setStatus({
kind: "error",
message: "Missing target tab information. Open this page from the extension popup."
});
return;
}

setIsBusy(true);
setStatus("");
setStatus(null);
setShareUrl("");
setAnnotations([]);
setSelectedId(null);
Expand All @@ -228,20 +231,20 @@ function EditorApp(): JSX.Element {
setPageUrl(result.pageUrl);
setProgress("Capture completed");
} catch (error) {
setStatus(error instanceof Error ? error.message : "Capture failed");
setStatus({ kind: "error", message: error instanceof Error ? error.message : "Capture failed" });
} finally {
setIsBusy(false);
}
};

const createShareUrl = async (): Promise<void> => {
if (!baseDataUrl) {
setStatus("Capture a screenshot before creating a share link.");
setStatus({ kind: "error", message: "Capture a screenshot before creating a share link." });
return;
}

setIsBusy(true);
setStatus("");
setStatus(null);

try {
const merged = await exportAnnotatedImage(baseDataUrl, annotations, { generalFeedback });
Expand All @@ -254,10 +257,13 @@ function EditorApp(): JSX.Element {
const localUrl = buildLocalShareUrl(share.id);
setShareUrl(localUrl);
await navigator.clipboard.writeText(localUrl);
setStatus("Local share link generated and copied to clipboard.");
setStatus({ kind: "success", message: "Local share link generated and copied to clipboard." });
await refreshSavedShares();
} catch (error) {
setStatus(error instanceof Error ? error.message : "Share creation failed");
setStatus({
kind: "error",
message: error instanceof Error ? error.message : "Share creation failed"
});
} finally {
setIsBusy(false);
}
Expand All @@ -269,7 +275,10 @@ function EditorApp(): JSX.Element {
await refreshSavedShares();
setShareUrl((current) => (current === buildLocalShareUrl(id) ? "" : current));
} catch (error) {
setStatus(error instanceof Error ? error.message : "Failed to delete saved share");
setStatus({
kind: "error",
message: error instanceof Error ? error.message : "Failed to delete saved share"
});
}
};

Expand Down Expand Up @@ -514,7 +523,7 @@ function EditorApp(): JSX.Element {

const download = async (): Promise<void> => {
if (!baseDataUrl) {
setStatus("Capture a screenshot before downloading.");
setStatus({ kind: "error", message: "Capture a screenshot before downloading." });
return;
}

Expand All @@ -524,15 +533,18 @@ function EditorApp(): JSX.Element {
a.href = merged;
a.download = `shotback-${Date.now()}.png`;
a.click();
setStatus("Annotated image downloaded.");
setStatus({ kind: "success", message: "Annotated image downloaded." });
} catch (error) {
setStatus(error instanceof Error ? error.message : "Failed to download image");
setStatus({
kind: "error",
message: error instanceof Error ? error.message : "Failed to download image"
});
}
};

const prepareExternalLlmPackage = async (): Promise<void> => {
if (!baseDataUrl) {
setStatus("Capture a screenshot before preparing LLM package.");
setStatus({ kind: "error", message: "Capture a screenshot before preparing LLM package." });
return;
}

Expand All @@ -550,16 +562,20 @@ function EditorApp(): JSX.Element {
a.click();

await navigator.clipboard.writeText(prompt);
setStatus(
"Prompt copied. Annotated image downloaded. Attach image to external LLM manually."
);
setStatus({
kind: "success",
message: "Prompt copied. Annotated image downloaded. Attach image to external LLM manually."
});
} catch (error) {
setStatus(error instanceof Error ? error.message : "Failed to prepare external LLM package");
setStatus({
kind: "error",
message: error instanceof Error ? error.message : "Failed to prepare external LLM package"
});
}
};

return (
<div className="grid min-h-screen grid-cols-1 gap-4 p-4 lg:grid-cols-[360px_1fr] lg:p-5">
<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">
<CardHeader className="space-y-3">
<div className="flex items-center justify-between">
Expand Down Expand Up @@ -655,16 +671,24 @@ function EditorApp(): JSX.Element {
</Button>
</div>

<div className="space-y-1 text-sm">
<div className="space-y-1 text-sm" aria-live="polite">
{progress ? <p className="m-0 text-slate-700">{progress}</p> : null}
{status ? <p className="m-0 font-medium text-red-700">{status}</p> : null}
{status ? (
<p
className={`m-0 font-medium ${
status.kind === "success" ? "text-emerald-700" : "text-red-700"
}`}
>
{status.message}
</p>
) : null}
<p className="m-0 text-slate-700">Annotations: {annotations.length}</p>
</div>

<Separator />
<section className="space-y-2">
<div className="flex items-center justify-between">
<h2 className="m-0 text-sm font-semibold">Comment Timeline</h2>
<h4 className="m-0 text-sm font-semibold">Comment Timeline</h4>
<Badge>{timelineItems.length}</Badge>
</div>
{timelineItems.length === 0 ? (
Expand Down Expand Up @@ -730,7 +754,7 @@ function EditorApp(): JSX.Element {
<Separator />
<section className="space-y-2">
<div className="flex items-center justify-between">
<h2 className="m-0 text-sm font-semibold">Saved Shares</h2>
<h4 className="m-0 text-sm font-semibold">Saved Shares</h4>
<div className="flex items-center gap-2">
<Badge>{savedShares.length}</Badge>
{savedShares.length > 0 ? (
Expand Down Expand Up @@ -1024,7 +1048,7 @@ function EditorApp(): JSX.Element {
)}
</CardContent>
</Card>
</div>
</main>
);
}

Expand Down
13 changes: 11 additions & 2 deletions src/popup/main.tsx
Original file line number Diff line number Diff line change
@@ -1,14 +1,18 @@
import { useState } from "react";
import { createRoot } from "react-dom/client";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import "@/styles/globals.css";

function PopupApp(): JSX.Element {
const [error, setError] = useState<string>("");

const openEditor = async (): Promise<void> => {
setError("");
const [tab] = await chrome.tabs.query({ active: true, currentWindow: true });
if (!tab?.id) {
window.alert("No active tab available");
setError("No active tab available. Focus a page tab, then try again.");
return;
}

Expand All @@ -31,10 +35,15 @@ function PopupApp(): JSX.Element {
Capture, annotate, and share visual feedback for LLM workflows.
</CardDescription>
</CardHeader>
<CardContent>
<CardContent className="space-y-2">
<Button className="w-full" onClick={() => void openEditor()}>
Open Capture Editor
</Button>
{error ? (
<p className="m-0 text-sm font-medium text-red-700" aria-live="polite">
{error}
</p>
) : null}
</CardContent>
</Card>
</main>
Expand Down
4 changes: 3 additions & 1 deletion src/styles/globals.css
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
@import "@fontsource-variable/manrope";

@tailwind base;
@tailwind components;
@tailwind utilities;
Expand All @@ -19,7 +21,7 @@

body {
@apply m-0 bg-background text-foreground antialiased;
font-family: "Manrope", "Segoe UI", Tahoma, sans-serif;
font-family: "Manrope Variable", "Segoe UI", Tahoma, sans-serif;
background:
radial-gradient(circle at 10% -20%, rgba(110, 231, 183, 0.32), transparent 38%),
radial-gradient(circle at 92% -28%, rgba(191, 219, 254, 0.5), transparent 42%),
Expand Down
Loading