# Build prompt: Shelf

Give this whole document to an AI coding assistant to build your own copy from scratch.

## What it is

A personal content library called **Shelf** — one home for the links, Google Docs, and PDFs people send you on social media, so they stop getting lost. Tagline: *"Everything worth keeping, in one place."* Two parts, both required: the web app itself, and a companion browser extension that feeds it.

## How to build it

Work in the phases below, in this order. Finish a phase, show the person what
now works, and wait for them before starting the next one. Do not disappear and
come back with a finished application that has never been run.

Some steps can only be done by the human — creating accounts, clicking consent
screens, loading an extension. Those are marked **HUMAN STEP**. When you reach
one: stop, say in plain language exactly what to click and where, say what they
should see when it worked, and wait. Do not guess your way past it.

**Phase 1 — the app, running locally, with no sync at all.**
Build `index.html`, `app.css`, `app.js` and the seed taxonomy. Items live in
`localStorage` for now. Get the add form, the grid, the reader, search and
filters working. At the end of this phase the person should be able to save a
link, find it, open it and delete it. Show them. Fix what they do not like now,
while there is nothing else to break.

**Phase 2 — the build script and the first deploy.**
Add `build.js`, self-host the fonts, then **HUMAN STEP**: they connect the repo
to Vercel, Netlify or GitHub Pages and give you back the live `https://` URL.
Nothing after this point can be tested without that URL, which is why it comes
before the Google work rather than after it.

**Phase 3 — Google Drive sync.**
Explain first, in two sentences, why this step exists at all: the app has no
server, so their own Drive is the only place the library can live that survives
clearing the browser. Then **HUMAN STEP**: walk them through Google Cloud
Console one instruction at a time — create a project, enable the Drive API,
configure the OAuth consent screen, create an OAuth client ID of type "Web
application", add the deployed origin from Phase 2 to Authorized JavaScript
origins with no trailing slash, copy the client ID. Tell them there is no client
secret in this flow and they should not go looking for one. Then build the
token flow, the folder/index creation, the read-merge-write push, and the
pending/offline badge. Verify together: save an item, reload with the cache
cleared, item still there.

**Phase 4 — the browser extension.**
Say why it exists before you build it: the page cannot read another site's
content, so without the extension a saved social post arrives with no caption.
Build the manifest, the three entry points and the options page. Then
**HUMAN STEP**: they open the browser's extensions page, turn on developer
mode, load the unpacked folder, open the options page and paste the deployed
URL from Phase 2. Test together on a real social post, not on a blank tab.

**Phase 5 — phone, then the quality pass.**
Give them the share-target URL shape for their own deploy, and offer to write
the iOS Shortcut steps or the Android share-target instructions for it. Then run
the accessibility bar and quality bar at the bottom of this document as an
actual checklist, reporting what passed and what you changed. Do not claim a
check you did not run.

Throughout: real UI copy from the first commit, never placeholders. If a
decision in this document conflicts with what you would normally do, this
document wins — and say so out loud rather than quietly substituting.

## Architecture

- **Web app — three source files**: `index.html` (markup shell only), `app.css` (all styles), `app.js` (all logic, vanilla JS, no framework). A tiny **build script** (`build.js`) inlines `app.css`/`app.js` back into `index.html` for the deployed page — one request instead of three — and self-hosts the webfonts (see Typography) so nothing depends on a font CDN.
- **Static hosting**: Vercel, Netlify, or GitHub Pages. Build command `npm run build`, output directory `public/`. Must be served over `https://` — Google's OAuth requires a declared origin.
- **Backend: your own Google Drive, and nothing else.** No database, no server holding user data. An *optional* small serverless piece is described under Authorization, purely to avoid re-clicking consent every hour.
- **Browser extension** (Manifest V3) as a sibling directory in the same repo — see its own section below.

## Data model

One resource ("item") looks like:

```
{
  id, title, url, source, type, topic,
  purpose,      // one line — why this is worth keeping
  summary,      // free text notes; becomes the article body
  content,      // summary split into paragraphs, for the reader view
  savedAt, updatedAt,
  fav,          // boolean
  pending,      // true until it has synced to Drive
  fileName, driveFileId, driveLink,   // only for uploaded files
  embedHeight   // remembered height if the user resized an embedded post
}
```

**Topic** and **Type** are user-owned lists, not hardcoded — see Taxonomy below. **Source** is *not* a list the user maintains: it's read off the URL every time (see Auto-detection). Keep these three separate — they answer three different questions (what's it about / what will you do with it / where did it come from) — don't collapse them into one generic "tags" concept.

Seed a first-run default taxonomy so the app isn't asking questions before it's shown anything:
- **Topics** (name + hex color each): 5–6 that make sense for you — e.g. Marketing, Design, AI, Productivity, Life, Finance — each a distinct hue from a ~15-color wheel-ordered palette (also used for new topics later, cycling through).
- **Types**: Guide, Template, Checklist, Tool, Reference. ("Post" and "Video" are deliberately absent — those are shapes the link already carries, not something worth asking about.)

## Taxonomy management

Topics and Types are edited from the UI, not the source code, from an "Edit" link next to each list heading in the filter rail. Per kind:
- **Add** a new entry (topics also get a color-swatch picker from the shared palette).
- **Rename** — cascades: every item already using the old name updates immediately. Case-only renames ("ai" → "AI") are allowed; renaming to a name that collides with a *different* existing entry is blocked with a clear message.
- **Delete** — blocked if any item still uses that entry, with an exact usage count ("3 items use this — rename or re-tag them first"). Always keep at least one entry per kind.
- Deduplicate entries case-insensitively (an imported backup or a sync merge should never leave two topics that only differ by case).
- If an item references a topic/type value missing from the current list (an old backup, a value added on another device before it synced), add it back automatically rather than leaving the item unfilterable.

## Adding content

Four ways in, all converging on the same "Add resource" form:
1. **Drop a link or a file** anywhere on the page.
2. **"Add resource" button** opens the form empty.
3. **A share-target deep link** — `?add=<url>&title=&text=` (or `?url=`) — for a phone's share sheet via a Shortcut, an Android share target, or a bookmarklet. Read once, then stripped from the address bar. Some share sheets dump everything into one text blob instead of filling the URL field separately — detect and pull the first `https://` link out of free text if the URL slot is empty.
4. **The browser extension** — see below; it opens the deep link from #3 with data the page itself could never have read.

The form: Link, Title (auto-suggested, editable), Source (shown as a read-only fact, not asked), Type, Topic, a one-line Notes field, and a longer Details field that becomes the reader-view body. Only a title is strictly required (falls back to the suggested one, or the filename for an upload).

**Auto-detect the source** from the URL host/path, in order of specificity — an uploaded file is always source `File` regardless of any URL attached to it. Recognize at least: Threads, Instagram, YouTube, TikTok, X, Substack, LinkedIn, Bluesky, Reddit, Facebook, Vimeo, GitHub (github.com/github.io only — a raw file URL is a file, not a repo), Google Doc (docs.google.com), Notion, PDF (by extension), falling back to Web page.

**Auto-suggest a title.** For a plain web page this is cosmetic. For a social link, do better than "A post on Instagram": read the title/description meta tags the platform hands over — many networks encode the author handle and even the caption there (a profile-card blurb, a "N likes, handle on Instagram" description). Extract the handle where you can find it, build a heading like "@handle on Network", and don't duplicate the same text into both title and notes. YouTube and TikTok expose a public oEmbed endpoint with the real author name — call it, debounced, only overwriting a title the user hasn't already edited by hand.

**Dropping more than one file at once is rejected** with an explanation — every resource needs its own notes; silently batch-saving without them isn't the same feature.

## Browser extension

Not an add-on bolted on later — build it alongside the app, because it solves a real capability gap the web app cannot solve on its own: a page's `og:description` and the user's text selection are only readable from *inside* that page, and most social platforms' oEmbed responses carry no text at all (cross-origin reads are blocked). Without the extension, a saved Instagram or Threads post has no caption to show.

Manifest V3, three entry points, all doing the same thing:
- Toolbar icon click.
- Right-click context menu on the page, a selection, a link, or an image/video — save the specific thing right-clicked (a right-clicked link saves that link, not the page it sits on; a right-clicked image/video saves its `srcUrl`, not the page).
- A configurable keyboard shortcut.

Behavior: read the current tab's URL, `og:title`/`og:description` (or the page `<title>`), and any active text selection (selection wins over the meta description if both are present). Strip a tab's unread-counter prefix ("(2) …") from the title. If the auto-read title and the selected/described text are near-duplicates, drop the title rather than showing the same words twice. Open a new tab pointed at the deployed app with `?add=<url>&title=&text=`, positioned next to the source tab. Falls back to just the raw URL on pages where script injection is refused (`chrome://` pages, the extension store, PDFs).

Settings: a single options page holding the deployed app's URL (defaults to wherever you host it), validated as a real URL before saving, with visible success/error feedback and a properly labeled `<form>` (Enter-to-submit, not click-only).

Ship a plain-language README with it: what it reads, what it explicitly doesn't (no background access, no history, nothing sent anywhere but your own deployed app), and how to load it unpacked during development.

## Filtering and search

Left rail, collapsible behind a "Filters" button below a phone-width breakpoint (auto-collapses again the moment a filter is picked, so the result lands where the tap happened instead of a screen below):
- **Full-text search** — title, notes, and body content.
- **Topic** — multi-select colored chips.
- **Source** — single-select list, built from what the user actually has (not a fixed menu), sorted by count, with a combined "All social" row when more than one social network is present.
- **Type** — single-select list, same "Edit" pattern as Topic.
- **Favorites only** toggle.
- **Clear all filters** link, shown only when something is active.

## Grid and reader

- **Grid**: responsive cards — source badge (small per-network icon; one icon per *kind of place* — video, photo, thread, doc — not an attempt at ten brand logos), title, a 2-line-clamped excerpt, a colored topic tag (color lives in a small dot next to the tag, never in the tag's own text, so it stays legible regardless of which palette color the topic got), the type as a small label, a "not synced" badge while pending, and a star-to-favorite button.
- **Reader** (opens on click, two-pane on wide screens): full body on the left; a right rail with **Notes** (the purpose line), **Details** (the longer body), an **Info** panel (source / domain / type / topic / date saved / stored-in), the raw **Link**, and **Actions** — Open original, Copy link, Favorite, Remove. "Back to shelf" returns to the grid without losing scroll position.
- **Known-network embeds**: when the link is a public Threads/Instagram post or a YouTube/TikTok video, embed it directly (an iframe pointed at the network's own embed endpoint) instead of just linking out. Give it a drag handle to resize (pointer + keyboard arrow-key support), remembered per item. Show a loading placeholder that only flips to "this may have been deleted" after the frame has had a moment to actually paint.
- **Remove is undo-able.** This is a library people fill with things worth keeping — a misclick shouldn't be permanent. Show a toast with an inline "Undo" action for a few seconds before the deletion is finalized (and before any linked file is actually sent to the Drive trash).

## Sync engine

Authorize once with the user's own Google account, scope `drive.file` **only** — the app can see just the files it created itself. On first connect: find or create a dedicated folder, and inside it one `shelf-index.json` holding the whole library (items, deleted-tombstones, taxonomy). Uploaded files go into that same folder as their own Drive files; the item just carries a reference. The browser keeps a local cache so reopening the app doesn't need a network round trip, but Drive is the source of truth — losing the cache loses nothing.

**Every write reads before it writes.** A push reads the current index from Drive, merges local state into it, then writes the merged result back — never blind-overwrite. Items merge one at a time (newest `updatedAt` wins per item; a delete is a tombstone with its own timestamp, so a stale re-sync can't resurrect an already-deleted item — prune tombstones after a long retention window, e.g. 90 days). **Taxonomy merges as a union, not a whole-object swap** — an entry only one side has (added on a device before the other's last sync) must survive the merge, not get dropped because the other side's edit happened to be newer.

**Guard the first-connect race**: two tabs/devices authorizing at nearly the same moment could each create their own folder/index, forking the library. After creating, re-check for a duplicate and converge everyone onto whichever is actually oldest.

Items added while offline/not-yet-connected save locally with a `pending` flag and a "not synced" badge, syncing automatically the moment a connection succeeds.

## Authorization

Two interchangeable modes, auto-detected:

1. **Client-only token flow** (works with just a static site). Google Identity Services' token client in the browser: a popup asks for consent once, hands back an access token good for about an hour, kept in `localStorage` with its expiry. Requires the user to create their own OAuth Client ID in Google Cloud Console (Drive API enabled, OAuth consent screen configured, the deployed origin added to "Authorized JavaScript origins") and paste it into a one-time setup dialog (or pass it once as `?client_id=...`). No client secret — this flow doesn't use one.
2. **Optional serverless refresh-token backend**, for people who don't want to re-click every hour: a few small functions (start / callback / logout / token) keeping a refresh token in an httpOnly, encrypted cookie, handing out fresh access tokens on request. If the required environment variables aren't set, detect that and fall back to mode 1 automatically.

Either way: the access token lives only long enough to talk to Drive from the browser. No database, no user accounts beyond "your own Google login."

## Backup

Export the whole library (items + taxonomy) as a downloadable, human-readable JSON file; re-import a backup file through the same sync/merge logic above, so it's additive, not a destructive overwrite. If an import also *removed* things via older tombstones, say so plainly rather than just reporting a net delta.

## Visual design

- Warm, not corporate-SaaS-blue. Cream background in light mode, warm charcoal/brown ground in dark mode — not a generic grey dark theme. Full light **and** dark themes via CSS custom properties, honoring both `prefers-color-scheme` and a manual toggle (`data-theme` attribute).
- One accent hue (warm orange/amber, gradient on the primary button and logo mark) for brand and active-state — but reserve a **separate hue** (blue) for actual hyperlinks inside content, so a clickable link never reads as the same signal as "this filter is active" or "this item isn't synced yet."
- Typography: a display/heading webface with real personality at tight letter-spacing (700–900) for headings, section labels, and buttons, paired with a plain, highly legible body face — two faces actually designed to sit together, not a display font paired with whatever the OS ships. Self-host both as `@font-face` so nothing depends on a font CDN.
- Flat or very soft shadows, warm hairline borders, generous but not excessive corner radius. Build a **small, explicit token scale** for spacing/radius/chip-sizing/shadow rather than picking pixel values ad hoc per component — visually-similar "chip" components (a source badge, a topic tag, a status pill) should share one padding/font-size token set, not each invent their own.

## Accessibility bar

Build in from the start, don't bolt on after:
- Every text/UI-boundary color combination hits **WCAG AA** (4.5:1 normal text, 3:1 for large text and interactive-component boundaries like a focus ring). If the accent brand color doesn't reach that on its own background, use a darkened variant for *text and rings*, keep the brighter version for large decorative fills only.
- **Keyboard-operable everything**: cards are `role="button"` + `tabindex="0"` + Enter/Space, never a real `<button>` nested inside another interactive element. Visible focus states everywhere (`:focus-visible`). Custom controls (a resize handle, a star toggle) get real keyboard handling.
- **Focus-trap every modal.** Tabbing past the last control in an open dialog cycles back to the first, never escapes onto whatever sits behind the overlay. Escape closes the dialog and returns focus to whatever opened it.
- Don't wrap a whole re-rendering panel in `aria-live` — it'll re-announce the entire thing on every minor update. Scope live regions tightly (a single result-count string, a toast); let real focus movement announce view changes like opening a reader.
- No heading-level skips (a card inside the main list is one level under the page's own `<h1>`, not two). Accessible names carry the same information a sighted user gets from surrounding visual context, not just the title.
- Respect `prefers-reduced-motion`. No horizontal page scroll at any width. Interactive touch targets at least 24×24px.

## Quality bar

- Real UI copy throughout — no lorem ipsum, no generic "Item 1" placeholders.
- Valid HTML; nothing nested that shouldn't be.
- Last-write-wins is an accepted, documented limitation for true same-second concurrent edits from two devices — this is a single-person tool. Don't over-engineer conflict resolution beyond the merge strategy above.
- The deployed page is publicly reachable at whatever URL you host it — anyone with the link sees an empty shell with a sign-in button, nothing more without your own Google login. Acceptable tradeoff; say so plainly in your own README.
