diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..aa1141b --- /dev/null +++ b/CLAUDE.md @@ -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=` (local-only page). +- `src/background.ts` — service worker; currently just an install log. `src/content.ts` — injected on ``; 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:` keys; the large PNG *blob* lives in IndexedDB (`shotback`/`shareImages`) keyed by `share-image:`. `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=")` — 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`, `` host access) — see `SECURITY.md` before touching `public/manifest.json`. diff --git a/package-lock.json b/package-lock.json index e78c0d2..44f011a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,6 +9,7 @@ "version": "0.1.0", "license": "MIT", "dependencies": { + "@fontsource-variable/manrope": "^5.2.8", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "react": "^18.3.1", @@ -483,6 +484,15 @@ "node": "^18.18.0 || ^20.9.0 || >=21.1.0" } }, + "node_modules/@fontsource-variable/manrope": { + "version": "5.2.8", + "resolved": "https://registry.npmjs.org/@fontsource-variable/manrope/-/manrope-5.2.8.tgz", + "integrity": "sha512-nc9lOuCRz73UHnovDE2bwXUdghE2SEOc7Aii0qGe3CLyE03W1a7VnY5Z6euRiapiKbCkGS+eXbY3s/kvWeGeSw==", + "license": "OFL-1.1", + "funding": { + "url": "https://github.com/sponsors/ayuhito" + } + }, "node_modules/@humanfs/core": { "version": "0.19.2", "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.2.tgz", diff --git a/package.json b/package.json index fe50249..cccd7c5 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/src/editor/main.tsx b/src/editor/main.tsx index b80c7a1..716ff06 100644 --- a/src/editor/main.tsx +++ b/src/editor/main.tsx @@ -120,7 +120,7 @@ function EditorApp(): JSX.Element { const [color, setColor] = useState("#ff3333"); const [generalFeedback, setGeneralFeedback] = useState(""); const [progress, setProgress] = useState(""); - const [status, setStatus] = useState(""); + const [status, setStatus] = useState<{ kind: "success" | "error"; message: string } | null>(null); const [shareUrl, setShareUrl] = useState(""); const [draft, setDraft] = useState(null); const [drag, setDrag] = useState(null); @@ -206,12 +206,15 @@ function EditorApp(): JSX.Element { const takeScreenshot = async (): Promise => { 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); @@ -228,7 +231,7 @@ 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); } @@ -236,12 +239,12 @@ function EditorApp(): JSX.Element { const createShareUrl = async (): Promise => { 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 }); @@ -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); } @@ -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" + }); } }; @@ -514,7 +523,7 @@ function EditorApp(): JSX.Element { const download = async (): Promise => { if (!baseDataUrl) { - setStatus("Capture a screenshot before downloading."); + setStatus({ kind: "error", message: "Capture a screenshot before downloading." }); return; } @@ -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 => { if (!baseDataUrl) { - setStatus("Capture a screenshot before preparing LLM package."); + setStatus({ kind: "error", message: "Capture a screenshot before preparing LLM package." }); return; } @@ -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 ( -
+
@@ -655,16 +671,24 @@ function EditorApp(): JSX.Element {
-
+
{progress ?

{progress}

: null} - {status ?

{status}

: null} + {status ? ( +

+ {status.message} +

+ ) : null}

Annotations: {annotations.length}

-

Comment Timeline

+

Comment Timeline

{timelineItems.length}
{timelineItems.length === 0 ? ( @@ -730,7 +754,7 @@ function EditorApp(): JSX.Element {
-

Saved Shares

+

Saved Shares

{savedShares.length} {savedShares.length > 0 ? ( @@ -1024,7 +1048,7 @@ function EditorApp(): JSX.Element { )} -
+
); } diff --git a/src/popup/main.tsx b/src/popup/main.tsx index 6443d79..d39d955 100644 --- a/src/popup/main.tsx +++ b/src/popup/main.tsx @@ -1,3 +1,4 @@ +import { useState } from "react"; import { createRoot } from "react-dom/client"; import { Badge } from "@/components/ui/badge"; import { Button } from "@/components/ui/button"; @@ -5,10 +6,13 @@ import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/com import "@/styles/globals.css"; function PopupApp(): JSX.Element { + const [error, setError] = useState(""); + const openEditor = async (): Promise => { + 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; } @@ -31,10 +35,15 @@ function PopupApp(): JSX.Element { Capture, annotate, and share visual feedback for LLM workflows. - + + {error ? ( +

+ {error} +

+ ) : null}
diff --git a/src/styles/globals.css b/src/styles/globals.css index 3f1e713..99c493c 100644 --- a/src/styles/globals.css +++ b/src/styles/globals.css @@ -1,3 +1,5 @@ +@import "@fontsource-variable/manrope"; + @tailwind base; @tailwind components; @tailwind utilities; @@ -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%),