Install

Markup runs as a Chrome extension locally on your machine. It works on localhost, file:// URLs, and live https:// sites.

Installation Steps

  1. Clone the repository
    git clone https://github.com/noachlevin/markup.git
    cd markup
  2. Install dependencies
    npm install
  3. Build the extension
    npm run build

    This creates a dist/ folder with the compiled extension.

  4. Load into Chrome

    Open chrome://extensions in a new tab.

  5. Enable Developer Mode

    Toggle the switch in the top-right corner.

  6. Load unpacked

    Click "Load unpacked" and select the dist/ folder from your cloned repo.

You should now see the Markup icon in your Chrome toolbar. You're ready to start annotating.

Your First Annotation

Here's the core loop — from opening the sidebar to saving your first note.

Step-by-step

  1. Open a web page — any site you want to annotate. Markup works on localhost, live sites, and file:// URLs.
  2. Click the Markup icon in your Chrome toolbar (top-right corner).
  3. The sidebar opens on the right side of your screen.
  4. Select an element (optional) — Click SELECT ELEMENT to highlight elements as you hover over the page. Click to lock your selection. The element's CSS selector appears in the Location field.
  5. Type your note — Click in the note text area and type your feedback, bug report, design observation, or question.
  6. Save the note — Click SAVE NOTE or press Cmd+Enter.

That's it. Your note is now stored and linked to that page. If you reload the page, the note will still be there.

Tip: You don't need to select an element. If you just want to leave a quick note without targeting a specific part of the page, just type and save. That's called a "general note."

Keyboard Shortcuts

Speed up your workflow with these keyboard shortcuts:

Shortcut Action
Cmd+Enter (Mac) or Ctrl+Enter (Windows) Save the current note
Esc Cancel/deactivate element selection mode
Via Wispr Flow (mic button) Dictate directly into the floating in-page input

The floating in-page input is where voice notes come in. See the Voice notes section for details.

Simple vs Dev Mode

Markup has two modes designed for different workflows:

Simple Mode (Default)

Screenshots + general notes only. No element selectors, CSS details, or severity levels. Perfect for quick feedback on design or UX. Just capture, annotate, and share.

Dev Mode

Full element selection, CSS selectors, type picker, severity levels, and context enrichment. Built for developers doing detailed code reviews and QA handoffs. More power, more detail.

How to toggle

Click the mode chip in the header (SIMPLE MODE or DEV MODE) to switch instantly. The first time you switch to Dev Mode, a one-time toast explains the new features. You can also change modes in Settings.

Your choice is saved and persists across sessions.

Select an Element

Dev Mode only. Element selection lets you pinpoint exactly what you're annotating — a button, form field, navigation menu, or any other part of the page.

How it works

  1. Click SELECT ELEMENT in the sidebar.
  2. A dashed blue ring appears. Move your mouse over the page — the ring follows you and highlights different elements.
  3. Click on an element to lock it. The ring turns solid gold.
  4. The element's CSS selector appears in the Location field at the bottom of the note form.
  5. The sidebar also shows the element's label (button text, input placeholder, ARIA label, etc.), its role (button, link, textbox, etc.), and its parent context (what contains it).

Selector quality

Markup generates CSS selectors in this priority order:

  1. Element ID (most specific)
  2. Meaningful class names (skips Tailwind utilities like p-4, hover:bg-blue)
  3. Semantic tag + class (e.g., button.primary)
  4. Semantic tag alone (e.g., button)
  5. Auto-generated fallback (most robust, but less human-readable)

The goal is a selector that's both readable and resilient to small DOM changes.

Pro tip: If the selector includes a framework-generated attribute (like data-reactid or UUID), Markup skips it in favor of something more stable.

General Notes

Not every note needs to be tied to a specific element. "General notes" are page-level feedback with no selector required.

When to use general notes

  • Page-wide design observations ("The spacing feels too tight")
  • Accessibility issues that span multiple elements
  • Questions about the entire feature or flow
  • Quick thoughts or TODOs without a specific target

How to create one

Simply type in the note textarea without selecting an element. The Location field stays empty. When you save, the note appears in your sidebar with "General note" as the label.

If you later want to attach the note to a specific element, you can always edit it and click SELECT ELEMENT to add a selector.

Screenshots

Screenshots are available in both Simple and Dev Mode. Capture a visual snippet of the page or element you're annotating.

How to capture

  1. Click SCREENSHOT in the sidebar.
  2. Your cursor changes to a crosshair.
  3. Click and drag to select the area of the page you want to capture.
  4. Release to save the screenshot as a PNG to your chosen folder.
  5. A thumbnail of the screenshot appears in the note card for quick reference.

Image storage

The first time you take a screenshot, Markup asks you to pick a folder where images will be saved. This uses the File System Access API, so you have full control over where your screenshots live. All images are stored as PNG files.

When you export your notes, screenshots are included in ZIP and PDF exports (but not in plain text or markdown-only exports).

Note: Thumbnails are stored in the browser's extension storage only for display in the sidebar. They're not included in JSON or CSV exports — only the file path is saved.

Voice Notes (Wispr Flow)

Markup integrates with Wispr Flow, a voice-to-text tool for Chrome. Dictate your thoughts directly into the note textarea using your microphone.

Setup

Install Wispr Flow from the Chrome Web Store. Once installed, you'll see a mic icon in Markup's settings.

Why a floating input?

The sidebar is technically a separate browsing context from the page you're viewing. Chrome's security boundary prevents the sidebar from directly focusing elements on the page. To work around this, Markup injects a hidden textarea into the page itself. This "floating input" is where Wispr Flow sends your dictated text. The content script then passes that text back to the sidebar, so it appears in your note.

In other words: you talk → Wispr Flow → page's floating input → sidebar's textarea. Seamless from your perspective, clever engineering under the hood.

How to use

  1. Go to the Wispr Flow settings in Markup.
  2. Click the mic button or use Wispr's global hotkey to start recording.
  3. Speak your note. Wispr transcribes in real-time.
  4. Stop recording and the text appears in your note.
  5. Click SAVE NOTE or press Cmd+Enter.
Tip: Voice notes are especially useful if you're testing on multiple pages and want to keep your hands free for interaction. Wispr also supports custom commands for punctuation.

Note Types

Dev Mode only. Every note is tagged with a type that categorizes what you're reporting. This helps organize your brief and makes handoffs clearer.

Bug

Something is broken. A button doesn't work, text is cut off, alignment is wrong, or the feature behaves unexpectedly.

Design

A visual or interaction critique. Colors, spacing, typography, animations, or UX flow refinements.

Copy

Writing feedback. Tone, clarity, grammar, or messaging. "This button label is confusing."

Question

Open-ended inquiry. "Why is this button here?" or "Is this feature still in use?"

General

Anything else. Thoughts, observations, TODOs, or multi-category feedback.

How to set the type

A type picker appears in the note form (4 buttons + General). Click the type that matches your feedback. The type is shown on every note card and used to group notes in the brief.

Severity Levels

Dev Mode only. Rank the urgency or impact of each note. Briefs sort by severity first, so critical issues bubble to the top.

Level When to use Example
Critical Blocks launch or breaks core functionality Login button doesn't work
High Major issue affecting user experience Navbar collapses on tablets
Medium (default) Worth fixing, but not urgent Button padding is 2px too wide
Low Nice-to-have or minor polish Tooltip could be more descriptive

Severity in the brief

When you generate a brief, notes are sorted into groups by severity (Critical → High → Medium → Low). Within each group, notes are sorted chronologically. This ensures the most important issues appear first, making the brief more actionable.

The brief header also includes a summary: "2 Critical, 5 High, 8 Medium, 1 Low" so the reviewer knows what they're dealing with at a glance.

Generating a Brief

A "brief" is a formatted summary of all your notes for a page or domain. It's designed to be pasted into Claude, ChatGPT, Cursor, or any AI tool for code review and feedback synthesis.

How to generate

  1. Click GENERATE BRIEF at the bottom of the notes list.
  2. A new panel opens showing the formatted brief.
  3. The brief includes a header with the page title, URL, date, and severity summary.
  4. Notes are grouped by severity, then sorted chronologically within each group.
  5. Each note includes its type, location (CSS selector), element label, and full text.

Scope

By default, the brief includes notes from all URLs on the same domain. For example, if you annotated myapp.com/dashboard, myapp.com/settings, and myapp.com/profile, the brief includes notes from all three.

This is intentional: it's common to review a feature across multiple pages, and you want all related feedback in one place.

Brief Format Reference

Here's the exact markdown structure of a generated brief:

# Markup Brief
**Domain:** myapp.com
**Generated:** 2026-03-06 at 2:45 PM
**Summary:** 2 Critical, 1 High, 3 Medium, 0 Low

---

## Critical Issues

### [1/2] Button does not respond to clicks
- **Type:** Bug
- **Severity:** Critical
- **Element:** button.submit
- **Location:** .form-container > button.submit
- **Parent:** form#login
- **When found:** 2026-03-06 at 2:30 PM

Login button is completely unresponsive. Checked console for errors.

---

### [2/2] Mobile layout breaks below 320px
- **Type:** Bug
- **Severity:** Critical
- **Element:** General note
- **When found:** 2026-03-06 at 2:35 PM

Text overflows the viewport on very narrow screens.

---

## High Issues

### [1/1] Primary color is too dark
- **Type:** Design
- **Severity:** High
- **Element:** body
- **Location:** body
- **Parent:** html
- **When found:** 2026-03-06 at 2:40 PM

The blue (#1A2744) reads as too heavy. Consider #2D4A8A.

---

## Medium Issues

[...additional notes...]

---

**Export options:** Copy to clipboard, Download as .md, Download as .pdf, Download as .zip (with images)

What's included

  • Header: Domain, generation time, severity summary
  • Grouped sections: Critical → High → Medium → Low
  • Note details: Type, severity, CSS selector, element label, parent context, timestamp
  • Full note text

This format is designed to be pasted directly into Claude or any AI tool. The structure and context clues help the AI understand what needs to be fixed and why.

Exporting

Once you've generated a brief, you have several export options depending on your workflow.

Format Includes Best for
Copy to clipboard Brief text (plain text) Paste into Claude, ChatGPT, Cursor, Linear
Download .md Brief text (markdown) Save as a file, include in GitHub issue, handoff via email
Download .zip Brief.md + images/ folder Complete package with screenshots, send to client or team
Download PDF Styled report + embedded images Share with non-technical stakeholders, archiving, printing
Export JSON All domain notes (raw data, no thumbnails) Programmatic use, data analysis, custom workflows
Export CSV Spreadsheet-friendly table (id, url, type, severity, selector, text, etc.) Import into spreadsheet, Excel review, data migration

Screenshots in exports

  • Plain text / .md: No images
  • .zip: All images in images/ folder, referenced in brief.md
  • PDF: Images embedded in the PDF (auto-printed from browser)
  • JSON / CSV: No thumbnails (only file path); images must be provided separately
Note: "Download PDF" uses your browser's print dialog. Select "Save as PDF" and Markup renders the full styled report with all embedded images. No external PDF library needed.

How Notes Are Stored

Markup stores all notes locally in your browser using Chrome's storage API. No data is sent to the internet.

Storage mechanism

Notes are stored in chrome.storage.local, a persistent key-value store sandboxed to the Markup extension. Your data stays on your computer — it never leaves your device and is only accessible to Markup running in your browser.

Key structure

Each URL gets its own storage key. The key is derived by normalizing the URL — stripping the hash fragment and trailing slash. For example:

  • https://myapp.com/dashboard
  • https://myapp.com/dashboard#settings
  • https://myapp.com/dashboard/

All three of these normalize to the same URL, so their notes are stored together under one key: markup_notes_myapp.com/dashboard.

Domain-scoped operations

The notes list, brief generation, and "Clear all notes" operate at the hostname level. When you generate a brief, Markup scans all notes for the current domain (e.g., myapp.com) regardless of path. When you clear all notes, it clears the entire domain.

Storage limits

Chrome's storage.local has a quota of 10MB per extension. For most users, this is plenty. If you're storing hundreds of notes with large screenshots, keep an eye on your usage. Export and archive old notes if needed.

Backups

Markup automatically creates backups of your notes to protect against accidental deletion.

How backups work

Before you perform any destructive operation (clear all notes, delete notes from a domain, etc.), Markup saves a snapshot of your notes. Each URL keeps the last 3 backups, with a timestamp.

Backups are stored under keys like markup_backup_myapp.com/dashboard and are inaccessible to you in the normal UI — they're your safety net if something goes wrong.

Recovery

If you accidentally delete notes and want to recover them, you'll need to export your storage data using Chrome's DevTools (not something we recommend for regular users). For now, the backup system prevents most irreversible accidents:

  • You confirm before clearing all notes
  • Individual delete has an undo option (briefly)
  • The backup is automatically written before any clear

We recommend regularly exporting your briefs to file as a long-term archive.

Clearing Notes

You have control over what gets deleted — domain-wide or per-note.

Delete a single note

Hover over a note card and click the trash icon. The note is deleted immediately. You'll have a brief moment to undo if you change your mind.

Clear all notes for a domain

Go to Settings → "Clear all notes for this domain." A confirmation dialog appears. Once confirmed, all notes for the current domain are permanently deleted and a backup is created.

Clear notes from a specific URL

If you've been taking notes on multiple pages of the same domain and want to clean up just one URL, delete notes individually. There's no bulk "clear this URL" action, but selective deletion gives you precision.

Important: Clearing notes is permanent. Backups help, but rely on them as a last resort. Export your notes to file before doing major cleanup.

Using with Claude

The brief output is designed to be pasted directly into a Claude conversation. Here's the workflow:

  1. Annotate your build — select elements, leave notes, set severity.
  2. Click GENERATE BRIEF in the sidebar.
  3. Click COPY TO CLIPBOARD or download the PDF/ZIP.
  4. Open Claude.ai (or any Claude-based tool).
  5. Paste the brief into a new conversation.

Suggested prompt framing

The brief is self-explanatory, but framing your prompt helps Claude prioritize:

Here's my review brief from Markup. Please fix all Critical and High issues first, then address Medium.
Use the exact CSS selectors provided — they are the precise elements I'm pointing at.

[PASTE BRIEF HERE]

Or for a targeted fix:

Fix only the Critical issues in this brief. Do not touch anything else.

[PASTE BRIEF HERE]

Why it works well

The brief format includes exact CSS selectors, element labels, parent context, and severity levels. Claude can map every issue to the correct line in your codebase without screenshots or ambiguous descriptions. The severity grouping tells Claude what to prioritize. The type labels (Bug / Design / Copy) tell it what kind of change is expected.

Tip: For complex sessions with many notes, paste the brief into Claude Projects or attach it as context so you can iterate across multiple conversations without repeating the full brief.

Using with VS Code / Cursor

The brief is designed to be pasted into any AI-assisted editor. The CSS selector format maps directly to elements in your HTML/JSX, making it easy for the AI to find exactly what needs fixing.

In VS Code (Copilot, Continue, or any AI chat extension)

  1. Generate and copy the brief from Markup.
  2. Open the AI chat panel in VS Code.
  3. Paste the brief with a framing prompt:
Fix the issues in this Markup review brief. Each issue includes an exact CSS selector — use it to find the element in the code. Fix Critical issues first.

[PASTE BRIEF HERE]

In Cursor

  1. Generate and copy the brief from Markup.
  2. Open Cursor's AI chat with Cmd+Shift+L.
  3. Paste the brief — Cursor will use the selectors to locate elements in your codebase.

Project context files (CLAUDE.md / .cursorrules)

If your project uses a CLAUDE.md or .cursorrules file, paste the brief inline as a supplemental block. Tell your AI to fix the issues, then remove the brief once they're resolved. Useful for multi-session review workflows.

Tip: Download the ZIP export (brief.md + images/) and drop the folder into your project root temporarily. This gives your AI editor both the structured brief and the visual context of your screenshots in one operation.
Open Source

Contributing to Markup

Markup is MIT-licensed and open for contributions. This section covers how the codebase is structured, how to set up a dev environment, and the architectural patterns you need to understand before touching anything.

View on GitHub ↗

Project Structure

Markup is a Chrome Extension built with vanilla JavaScript and esbuild. Here's the layout:

markup/
├── src/
│   ├── content/
│   │   └── content.js       # Injected into pages
│   ├── sidebar/
│   │   ├── sidebar.js       # Side panel logic
│   │   ├── sidebar.html     # Side panel markup
│   │   └── sidebar.css      # All styles + design tokens
│   └── background.js        # Service worker
├── build.js                 # esbuild config
├── dist/                    # Build output (load unpacked here)
├── package.json
└── README.md

Key files explained

  • content.js: Runs in the page context. Handles hover ring, element selection, screenshot overlay, and the floating in-page textarea for voice notes. Communicates with sidebar via messages.
  • sidebar.js: Side panel UI logic. Manages notes state, storage, brief generation, exports, image handling, and archive.
  • sidebar.html: Markup for the sidebar UI.
  • sidebar.css: All component styles, animations, and design tokens. Self-hosted fonts in dist/fonts/.
  • background.js: Service worker. Registers the side panel and handles screenshot capture via chrome.tabs.captureVisibleTab.
  • build.js: esbuild script. Bundles content.js and copies sidebar files + fonts to dist/.

Build process

Run npm run build to compile. esbuild minifies content.js and the sidebar assets are copied as-is. The dist/ folder is what you load into Chrome.

Adding Note Types

Want to add a new note type (e.g., "Performance", "Accessibility")? Here's where to add it:

In sidebar.js

Find the type picker array (search for typePickerButtons or similar):

const noteTypes = [
  { id: 'bug', label: 'Bug', color: 'var(--mid-blue)' },
  { id: 'design', label: 'Design', color: 'var(--gold)' },
  { id: 'copy', label: 'Copy', color: 'var(--slate)' },
  { id: 'question', label: 'Question', color: 'var(--deep-blue)' },
  // Add your new type here:
  { id: 'performance', label: 'Performance', color: 'var(--warm-white)' },
];

In sidebar.css

Add a color class for the badge:

.note-type-performance {
  background: var(--warm-white);
  color: var(--ink);
}

In the brief template

Update the brief generation logic to include your new type in the header summary and group sections.

In storage

No changes needed — the type is just a string field on each note. It'll serialize/deserialize automatically.

Tip: Pick a color from the design tokens (gold, blue, slate, etc.) that visually matches the type's meaning. "Performance" might be orange/warning-adjacent, while "Accessibility" could be green/success.

Contributor Setup

Before opening a PR, a few things to know about the project setup:

Prerequisites

  • Node.js 18+ — check .nvmrc in the repo root for the pinned version. Run nvm use if you have nvm installed.
  • npm — comes with Node. Run npm install after cloning.
  • Chrome 114+ — required for the Side Panel API (chrome.sidePanel). Chromium-based browsers (Edge, Brave) may work but are not tested.

Development workflow

  1. Fork the repo on GitHub.
  2. Clone your fork: git clone https://github.com/YOUR_USERNAME/Markup.git
  3. Install deps: npm install
  4. Build: npm run build — output goes to dist/
  5. Load unpacked in Chrome: chrome://extensions → Enable Developer Mode → Load unpacked → select dist/
  6. Make changes in src/
  7. Run npm run build after each change, then reload the extension in Chrome.

Automated tests

There are currently no automated tests. QA is manual: load the extension unpacked, test the core loop (annotate → generate brief → export), and test in both Simple Mode and Dev Mode. If you're adding a feature, test on both localhost:PORT and a live https:// site.

PR expectations

  • One feature or fix per PR.
  • Describe what you changed and why in the PR description.
  • If you changed any storage keys, update the Storage Keys table in CLAUDE.md.
  • If you changed the note data model, update the Note Data Model section in CLAUDE.md.

See CONTRIBUTING.md for the full contributor guide.

Architecture Notes for Contributors

A few patterns in the codebase that are non-obvious and worth understanding before touching anything:

Content script ↔ sidebar message protocol

The sidebar and the page are separate browsing contexts. They communicate exclusively via chrome.runtime.sendMessage (sidebar → content) and chrome.runtime.onMessage (content → sidebar). Every feature that crosses this boundary uses this protocol. Key message types:

  • MARKUP_ACTIVATE / MARKUP_DEACTIVATE — toggle element selection mode
  • ELEMENT_SELECTED — content script reports selected element + selector
  • ELEMENT_DESELECTED — element was deselected
  • CAPTURE_SCREENSHOT — sent to background.js to trigger captureVisibleTab
  • NOTE_TEXT_UPDATE — floating input text passed from page to sidebar

The ignoreNextDeselect flag

deactivate() in content.js always fires ELEMENT_DESELECTED. When the sidebar sends MARKUP_DEACTIVATE, this flag is set first. The handler checks it to prevent flushSave() being called on deactivation (which would save a blank note). If you touch activation/deactivation flow, watch this flag carefully.

deactivateReset() vs resetForm()

deactivateReset() preserves the current note textarea value (used when deactivation happens around a note in progress). resetForm() clears everything. Only call resetForm() after an explicit user action (save, cancel). Never call it on deactivation.

Storage keys are URL-based, not tab-based

All storage uses normalizeUrl(url) as the key — strips #hash and trailing slash. Notes survive tab closes and reopens. Domain-scoped operations scan all markup_notes_ keys matching the hostname.

Floating input for Wispr Flow

Chrome's Side Panel is a separate browsing context. focus() from the sidebar cannot target page elements. To receive Wispr Flow voice input, a textarea is injected into the page by the content script (which lives in the page context). This floating input receives voice text and passes it to the sidebar via messaging.