diff --git a/README.md b/README.md
index 9b857f3..10f4afc 100644
--- a/README.md
+++ b/README.md
@@ -14,8 +14,13 @@ Tabby is a Manifest V3 extension (Chrome and Edge) that replaces the new tab pag
- "New" and "Senior" badges are computed server-side from optional RescueGroups fields — `availableDate` (with an adopted-then-returned branch keyed on `adoptedDate`) for New, `birthDate` with an `ageGroup` fallback for Senior. When the field an org needs isn't populated, that cat is simply excluded from the badge's evaluation rather than guessed at.
- Content-aware crop for portrait photos: a row-wise edge-energy heuristic finds the likely subject band instead of always anchoring to the top, via a small hostname-locked analysis-thumbnail proxy (`GET /api/photo-thumb`) that works around RescueGroups' CDN sending no CORS headers.
- "Share this cat" via the Web Share API, attaching the actual photo (fetched through a second hostname-locked, share-sized proxy, `GET /api/photo-share`, for the same CORS reason as the crop analysis above) alongside the cat's details, a Tabby tagline, and its RescueGroups profile link. Degrades to a link-only native share if the photo can't be attached, and to a clipboard-copy if the platform has no Web Share API at all.
+- Save a cat via the heart icon on its photo: stores a snapshot (name, photo, rescue/profile links, etc.) in `chrome.storage.local`, not `.sync` — see the "Saved cats storage" note below for why — so a saved card still renders on the dedicated Saved cats page (`extension/saved.html`) even after the cat is adopted or drops out of the regular feed.
- No third-party runtime dependencies; Node's built-in test runner.
+### Saved cats storage (issue #43)
+
+Saved cats persist via `chrome.storage.local`, not `chrome.storage.sync`. Sync would survive an uninstall or follow the user to a new device, which is a better fit for "save something to revisit indefinitely" — but `webstore/WEBSTORE.md`'s Permissions Justification table explicitly promises Chrome Web Store reviewers that Tabby's `storage` permission never uses `chrome.storage.sync` and that nothing is synced to Google's servers. Switching to sync would mean deliberately rewriting that reviewed privacy claim, not just a code change, so for now saved cats stay local-only. Revisit if cross-device persistence becomes a real ask.
+
## Run locally
1. Copy `.env.example` to `.env` and fill in your real `RG_API_KEY`. `.env` is gitignored and never committed — never place the key in extension code either.
diff --git a/extension/faq.html b/extension/faq.html
index 8503566..bf05fc1 100644
--- a/extension/faq.html
+++ b/extension/faq.html
@@ -77,6 +77,10 @@
Using Tabby
How do I share a cat with a friend?
Click the share icon on any cat card. You can send it via text, email, or social media, or just copy the link.
+
+ How do I save a cat to look at later?
+
Click the heart icon on a cat's photo to save it. Saved cats stay on your Saved cats page (the heart icon in the header) even after they're adopted elsewhere or drop out of your regular feed. Saved cats are stored only on this device, not synced across your other computers or backed up anywhere else. Uninstalling Tabby, or using your browser's "Clear browsing data" with site data included, will erase your saved list.
+ Is Tabby free?
Yes, completely. No ads, no trackers, no premium tier.
diff --git a/extension/newtab.js b/extension/newtab.js
index 6828f7d..1fa4692 100644
--- a/extension/newtab.js
+++ b/extension/newtab.js
@@ -42,6 +42,14 @@ const TALL_PORTRAIT_HEIGHT_RATIO = 1.3;
const PORTRAIT_ANALYSIS_MIN_CONFIDENCE = 1.1;
const PORTRAIT_ANALYSIS_TIMEOUT_MS = 5000;
const PHOTO_SHARE_TIMEOUT_MS = 6000;
+// Issue #69: a network failure (photo load, refresh, or explore) previously
+// surfaced the same message it would for an unrelated cause (a removed
+// photo, a bad ZIP), which is actively misleading when the real problem is
+// just "no internet". navigator.onLine is a reliable true-negative signal
+// -- it can't be true while genuinely offline -- so it's checked first and
+// gets its own message; a false "online" reading just falls through to the
+// existing per-context error handling unchanged.
+const OFFLINE_MESSAGE = "You're offline. Reconnect and refresh to keep browsing cats.";
const TABBY_CWS_URL = "https://chromewebstore.google.com/detail/tabby-new-tab-for-adoptab/elfpnkoboidkgahmoggodpnmekfodcig";
const TABBY_EDGE_URL = "https://microsoftedge.microsoft.com/addons/detail/fieeoalehgckgnkohkdblljmgaemaiho";
const TABBY_TAGLINE = "Meet an adoptable cat every time you open a new tab.";
@@ -95,6 +103,57 @@ const EXPLORE_LOCATIONS = [
function storageGet(keys) { return chrome.storage.local.get(keys); }
function storageSet(value) { return chrome.storage.local.set(value); }
+
+// Issue #43: saved cats persist via chrome.storage.local (not .sync) --
+// WEBSTORE.md's permissions justification explicitly tells reviewers Tabby
+// never syncs data to Google's servers, and changing that is a real
+// privacy-posture decision, not just a code choice. See GitHub issue #43
+// for that trade-off (sync would survive an uninstall/new device, local
+// doesn't) if this ever needs revisiting. A snapshot is stored at save time
+// rather than just the id, since a saved cat can be adopted or delisted
+// later and the saved list should still show something for it.
+async function getSavedCats() {
+ const { savedCats } = await storageGet(["savedCats"]);
+ return Array.isArray(savedCats) ? savedCats : [];
+}
+
+function isCardSaved(id, savedCats) {
+ return savedCats.some((saved) => saved.id === id);
+}
+
+function snapshotForSave(card) {
+ return {
+ id: card.id,
+ name: card.name,
+ breed: card.breed || null,
+ age: card.age || null,
+ sex: card.sex || null,
+ imageUrl: card.imageUrl,
+ originalImageUrl: card.originalImageUrl || null,
+ rescueName: card.rescueName,
+ rescueUrl: card.rescueUrl || null,
+ profileUrl: card.profileUrl || null,
+ adoptionFee: card.adoptionFee || null,
+ savedAt: new Date().toISOString()
+ };
+}
+
+// Returns the new saved state (true = now saved) so the caller can update
+// the toggle button without a second read.
+async function toggleSaveCard(card) {
+ const savedCats = await getSavedCats();
+ const existingIndex = savedCats.findIndex((saved) => saved.id === card.id);
+ const nextSaved = existingIndex < 0 ? [...savedCats, snapshotForSave(card)] : savedCats.filter((_, i) => i !== existingIndex);
+ try {
+ await storageSet({ savedCats: nextSaved });
+ } catch (error) {
+ console.error("[tabby]", error);
+ showNotice("Couldn't update your saved cats right now. Try again.", { type: "error" });
+ return existingIndex >= 0; // unchanged
+ }
+ return existingIndex < 0;
+}
+
function randomCard(cards) { return cards[Math.floor(Math.random() * cards.length)]; }
function setCardVisible(visible) {
const card = $("card");
@@ -281,6 +340,63 @@ function getSeenIds(feedCache) {
return Array.isArray(feedCache?.seenIds) ? feedCache.seenIds : [];
}
+const SVG_NS = "http://www.w3.org/2000/svg";
+// Same heart glyph as the header's "Saved cats" icon-btn (extension/newtab.html)
+// -- built via the DOM rather than innerHTML to match this file's "no
+// innerHTML" convention (see renderCard's "Clear securely" comment), even
+// though this particular markup is static, not user data.
+const HEART_PATH_D = "M19 14c1.49-1.46 3-3.21 3-5.5A5.5 5.5 0 0 0 16.5 3c-1.76 0-3 .5-4.5 2-1.5-1.5-2.74-2-4.5-2A5.5 5.5 0 0 0 2 8.5c0 2.3 1.5 4.05 3 5.5l7 7Z";
+
+function buildHeartIcon() {
+ const svg = document.createElementNS(SVG_NS, "svg");
+ svg.setAttribute("viewBox", "0 0 24 24");
+ svg.setAttribute("stroke", "currentColor");
+ svg.setAttribute("stroke-width", "2");
+ svg.setAttribute("stroke-linecap", "round");
+ svg.setAttribute("stroke-linejoin", "round");
+ svg.setAttribute("aria-hidden", "true");
+ svg.setAttribute("focusable", "false");
+ const path = document.createElementNS(SVG_NS, "path");
+ path.setAttribute("d", HEART_PATH_D);
+ svg.appendChild(path);
+ return svg;
+}
+
+function setSaveButtonState(button, card, saved) {
+ button.setAttribute("aria-pressed", String(saved));
+ const label = saved ? `Unsave ${card.name}` : `Save ${card.name}`;
+ button.setAttribute("aria-label", label);
+ button.title = label;
+ button.classList.toggle("saved", saved);
+ button.querySelector("svg").setAttribute("fill", saved ? "currentColor" : "none");
+}
+
+// Header "Saved cats" icon recolors (via currentColor, not a filled heart --
+// see .icon-btn.has-saved in newtab.css) once there's at least one saved
+// cat, so there's a passive hint the list isn't empty without implying the
+// nav icon itself is a toggle.
+async function updateSavedHeaderIndicator() {
+ const savedCats = await getSavedCats();
+ $("saved").classList.toggle("has-saved", savedCats.length > 0);
+}
+
+function buildSaveButton(card) {
+ const button = document.createElement("button");
+ button.type = "button";
+ button.className = "save-btn";
+ button.appendChild(buildHeartIcon());
+ setSaveButtonState(button, card, false);
+ getSavedCats().then((savedCats) => setSaveButtonState(button, card, isCardSaved(card.id, savedCats)));
+ button.addEventListener("click", async () => {
+ button.disabled = true;
+ const saved = await toggleSaveCard(card);
+ setSaveButtonState(button, card, saved);
+ updateSavedHeaderIndicator();
+ button.disabled = false;
+ });
+ return button;
+}
+
function nextCard(cards, seenIds = []) {
const seenSet = new Set(seenIds);
const unseenCards = cards.filter((card) => !seenSet.has(card.id));
@@ -308,6 +424,15 @@ function renderCard(card, { stale = false, exploreLabel = null, locationLabel =
const badges = [card.isNew && { label: "New", className: "new" }, card.isSenior && { label: "Senior", className: "senior" }].filter(Boolean);
const rescueUrl = card.rescueUrl || card.profileUrl;
const profileUrl = card.profileUrl;
+ // Issue #51: ~85% of orgs don't configure a per-animal profile URL, so
+ // rescueUrl and profileUrl end up pointing at the exact same page (either
+ // because rescueUrl's own fallback above kicks in, or because the org's
+ // "animal profile" URL RescueGroups hands back is just its own homepage).
+ // Showing two links to the same destination reads as a mistake, not a
+ // feature -- when they match, "View profile" (the styled action button,
+ // already what feeds the share flow) stays as the one link, and the
+ // rescue name above it drops back to plain text.
+ const showRescueLink = Boolean(rescueUrl) && rescueUrl !== profileUrl;
const cardContainer = $("card");
cardContainer.className = "card";
@@ -322,7 +447,10 @@ function renderCard(card, { stale = false, exploreLabel = null, locationLabel =
img.src = card.imageUrl;
img.alt = card.name;
img.referrerPolicy = "no-referrer";
- img.addEventListener("error", () => { showNotice("That photo is no longer available. Refresh to try another cat.", { type: "error" }); });
+ img.addEventListener("error", () => {
+ const message = navigator.onLine === false ? OFFLINE_MESSAGE : "That photo is no longer available. Refresh to try another cat.";
+ showNotice(message, { type: "error" });
+ });
img.addEventListener("load", () => {
// A portrait-oriented photo (taller than wide) can't fill the card's
// full width without either cropping or shrinking down to fit beside
@@ -355,6 +483,8 @@ function renderCard(card, { stale = false, exploreLabel = null, locationLabel =
photoFrame.appendChild(badgesDiv);
}
+ photoFrame.appendChild(buildSaveButton(card));
+
cardContainer.appendChild(photoFrame);
const content = document.createElement("div");
@@ -411,7 +541,7 @@ function renderCard(card, { stale = false, exploreLabel = null, locationLabel =
const rescueP = document.createElement("p");
rescueP.className = "rescue";
- if (rescueUrl) {
+ if (showRescueLink) {
const rescueA = document.createElement("a");
rescueA.href = rescueUrl;
rescueA.target = "_blank";
@@ -792,14 +922,18 @@ async function _start({ requestLocation = false } = {}) {
if (shouldRefresh) {
try { await refresh(location, locationLabel); } catch (error) {
console.error("[tabby]", error);
- const finalMessage = classifyRefreshError(error.message);
- // A ZIP-validation failure already carries enough detail to know it's
- // a user issue, so it keeps the settings shortcut instead — the
- // report-issue link is for failures that might actually be our bug.
- const noticeOptions = isInvalidZipError(error.message)
- ? { links: [ZIP_SETTINGS_LINK], type: "error" }
- : { links: [{ text: "Report an issue", action: "report-issue" }], type: "error" };
- showNotice(finalMessage, noticeOptions);
+ if (navigator.onLine === false) {
+ showNotice(OFFLINE_MESSAGE, { type: "error" });
+ } else {
+ const finalMessage = classifyRefreshError(error.message);
+ // A ZIP-validation failure already carries enough detail to know it's
+ // a user issue, so it keeps the settings shortcut instead — the
+ // report-issue link is for failures that might actually be our bug.
+ const noticeOptions = isInvalidZipError(error.message)
+ ? { links: [ZIP_SETTINGS_LINK], type: "error" }
+ : { links: [{ text: "Report an issue", action: "report-issue" }], type: "error" };
+ showNotice(finalMessage, noticeOptions);
+ }
if (!feedCache?.cards?.length) $("location-panel").hidden = false;
}
}
@@ -846,7 +980,7 @@ async function exploreArea() {
console.error("[tabby]", error);
exploreBatch = null;
hideExploreBanner();
- showNotice("Unable to explore that area right now. Try again.", { type: "error" });
+ showNotice(navigator.onLine === false ? OFFLINE_MESSAGE : "Unable to explore that area right now. Try again.", { type: "error" });
}
}
@@ -882,8 +1016,18 @@ function openFaq() {
});
}
+function openSaved() {
+ chrome.tabs.query({ active: true, currentWindow: true }, (tabs) => {
+ const [tab] = tabs;
+ if (tab?.id) {
+ chrome.tabs.update(tab.id, { url: chrome.runtime.getURL("extension/saved.html") });
+ }
+ });
+}
+
$("settings").addEventListener("click", openSettings);
$("faq").addEventListener("click", openFaq);
+$("saved").addEventListener("click", openSaved);
$("use-location").addEventListener("click", async () => {
$("location-panel").hidden = true;
showNotice("Finding your location…");
@@ -898,3 +1042,4 @@ $("back-to-my-area").addEventListener("click", async () => {
await start();
});
start();
+updateSavedHeaderIndicator();
diff --git a/extension/saved.html b/extension/saved.html
new file mode 100644
index 0000000..abf35d0
--- /dev/null
+++ b/extension/saved.html
@@ -0,0 +1,19 @@
+
+
+ Tabby saved cats
+
+
+
+
SAVED CATS
+
+
+
+
Saved cats
+
Cats you've saved from the new tab page. Saved cats stay here even after they're adopted elsewhere or drop out of your regular feed. They're saved only on this device, not in the cloud, so they won't survive things like uninstalling Tabby.
+
You haven't saved any cats yet. Tap the heart on a cat's photo to save it here.
+
+
+
+
+
+
diff --git a/extension/saved.js b/extension/saved.js
new file mode 100644
index 0000000..42744e7
--- /dev/null
+++ b/extension/saved.js
@@ -0,0 +1,181 @@
+const $ = (id) => document.getElementById(id);
+
+// Mirrors faq.js's close/back navigation.
+$("back-to-tabby").addEventListener("click", () => {
+ chrome.tabs.query({ active: true, currentWindow: true }, (tabs) => {
+ const [tab] = tabs;
+ if (tab?.id) {
+ const extensionUrl = chrome.runtime.getURL("extension/newtab.html");
+ chrome.tabs.create({ url: extensionUrl, active: true }, () => {
+ chrome.tabs.remove(tab.id);
+ });
+ return;
+ }
+ window.close();
+ });
+});
+
+async function getSavedCats() {
+ const { savedCats } = await chrome.storage.local.get(["savedCats"]);
+ return Array.isArray(savedCats) ? savedCats : [];
+}
+
+async function removeSavedCat(id) {
+ const savedCats = await getSavedCats();
+ await chrome.storage.local.set({ savedCats: savedCats.filter((saved) => saved.id !== id) });
+}
+
+const SVG_NS = "http://www.w3.org/2000/svg";
+function buildCloseIcon() {
+ const svg = document.createElementNS(SVG_NS, "svg");
+ svg.setAttribute("viewBox", "0 0 24 24");
+ svg.setAttribute("fill", "none");
+ svg.setAttribute("stroke", "currentColor");
+ svg.setAttribute("stroke-width", "2");
+ svg.setAttribute("stroke-linecap", "round");
+ svg.setAttribute("stroke-linejoin", "round");
+ svg.setAttribute("aria-hidden", "true");
+ svg.setAttribute("focusable", "false");
+ for (const d of ["M18 6 6 18", "m6 6 12 12"]) {
+ const path = document.createElementNS(SVG_NS, "path");
+ path.setAttribute("d", d);
+ svg.appendChild(path);
+ }
+ return svg;
+}
+
+// Small hand-built lightbox (no external library) -- shows the
+// original-resolution photo full-screen, dismissed by clicking outside the
+// image, the close button, or Escape.
+function openLightbox(imageUrl, altText) {
+ const overlay = document.createElement("div");
+ overlay.className = "lightbox-overlay";
+ overlay.setAttribute("role", "dialog");
+ overlay.setAttribute("aria-modal", "true");
+ overlay.setAttribute("aria-label", altText);
+
+ const img = document.createElement("img");
+ img.className = "lightbox-img";
+ img.src = imageUrl;
+ img.alt = altText;
+ img.referrerPolicy = "no-referrer";
+ overlay.appendChild(img);
+
+ const closeBtn = document.createElement("button");
+ closeBtn.type = "button";
+ closeBtn.className = "lightbox-close";
+ closeBtn.setAttribute("aria-label", "Close");
+ closeBtn.appendChild(buildCloseIcon());
+ overlay.appendChild(closeBtn);
+
+ function close() {
+ overlay.remove();
+ document.removeEventListener("keydown", onKeydown);
+ }
+ function onKeydown(event) {
+ if (event.key === "Escape") close();
+ }
+ overlay.addEventListener("click", (event) => { if (event.target === overlay) close(); });
+ closeBtn.addEventListener("click", close);
+ document.addEventListener("keydown", onKeydown);
+
+ document.body.appendChild(overlay);
+ closeBtn.focus();
+}
+
+function buildSavedItem(saved) {
+ const item = document.createElement("div");
+ item.className = "saved-item";
+
+ const photoBtn = document.createElement("button");
+ photoBtn.type = "button";
+ photoBtn.className = "saved-item-photo-btn";
+ const photoHint = `Click to see a larger photo of ${saved.name}`;
+ photoBtn.setAttribute("aria-label", photoHint);
+ photoBtn.title = photoHint;
+ const img = document.createElement("img");
+ img.className = "saved-item-photo";
+ img.src = saved.imageUrl;
+ img.alt = saved.name;
+ img.referrerPolicy = "no-referrer";
+ photoBtn.appendChild(img);
+ photoBtn.addEventListener("click", () => openLightbox(saved.originalImageUrl || saved.imageUrl, saved.name));
+ item.appendChild(photoBtn);
+
+ const info = document.createElement("div");
+ info.className = "saved-item-info";
+
+ const name = document.createElement("p");
+ name.className = "saved-item-name";
+ name.textContent = saved.name;
+ info.appendChild(name);
+
+ const meta = [saved.breed, saved.age, saved.sex].filter(Boolean).join(" · ");
+ if (meta || saved.adoptionFee) {
+ const metaP = document.createElement("p");
+ metaP.className = "saved-item-meta";
+ metaP.textContent = [meta, saved.adoptionFee].filter(Boolean).join(" · ");
+ info.appendChild(metaP);
+ }
+
+ // Same redundancy logic as newtab.js's renderCard (issue #51): when the
+ // rescue and profile URLs are the same page, link only the rescue name
+ // here instead of showing two links to one destination.
+ const rescueUrl = saved.rescueUrl || saved.profileUrl;
+ const showRescueLink = Boolean(rescueUrl) && rescueUrl !== saved.profileUrl;
+ if (rescueUrl || saved.profileUrl) {
+ const linksP = document.createElement("p");
+ linksP.className = "saved-item-links";
+ if (showRescueLink) {
+ const rescueA = document.createElement("a");
+ rescueA.href = rescueUrl;
+ rescueA.target = "_blank";
+ rescueA.rel = "noreferrer";
+ rescueA.textContent = saved.rescueName || "Rescue";
+ linksP.appendChild(rescueA);
+ } else {
+ linksP.appendChild(document.createTextNode(saved.rescueName || "Rescue"));
+ }
+ if (saved.profileUrl) {
+ if (linksP.hasChildNodes()) linksP.appendChild(document.createTextNode(" · "));
+ const profileA = document.createElement("a");
+ profileA.href = saved.profileUrl;
+ profileA.target = "_blank";
+ profileA.rel = "noreferrer";
+ profileA.textContent = "View profile";
+ linksP.appendChild(profileA);
+ }
+ info.appendChild(linksP);
+ }
+
+ item.appendChild(info);
+
+ const removeBtn = document.createElement("button");
+ removeBtn.type = "button";
+ removeBtn.className = "saved-item-remove";
+ removeBtn.textContent = "Remove";
+ removeBtn.addEventListener("click", async () => {
+ removeBtn.disabled = true;
+ await removeSavedCat(saved.id);
+ await render();
+ });
+ item.appendChild(removeBtn);
+
+ return item;
+}
+
+async function render() {
+ const savedCats = await getSavedCats();
+ const list = $("saved-list");
+ list.textContent = ""; // Clear securely
+
+ $("saved-empty").hidden = savedCats.length > 0;
+
+ // Most recently saved first.
+ const sorted = [...savedCats].sort((a, b) => new Date(b.savedAt) - new Date(a.savedAt));
+ for (const saved of sorted) {
+ list.appendChild(buildSavedItem(saved));
+ }
+}
+
+render();
diff --git a/test/newtab.test.js b/test/newtab.test.js
index f91f6db..62f9be4 100644
--- a/test/newtab.test.js
+++ b/test/newtab.test.js
@@ -166,6 +166,218 @@ describe('newtab.js DOM manipulation', () => {
assert.equal(card.querySelectorAll('.badges').length, 0);
});
+ describe('rescue/profile link redundancy (issue #51)', () => {
+ it('drops the rescue-name link and keeps only View profile when both URLs match', () => {
+ window.renderCard({
+ name: "Milo",
+ rescueName: "Second Chance Rescue",
+ rescueUrl: "https://rescue.org",
+ profileUrl: "https://rescue.org",
+ imageUrl: "https://image.org/cat.jpg"
+ });
+ const card = document.getElementById("card");
+ const rescueP = card.querySelector('.rescue');
+ assert.equal(rescueP.querySelector('a'), null, 'rescue name should render as plain text');
+ assert.equal(rescueP.textContent, "Second Chance Rescue");
+ const profileA = card.querySelector('.profile');
+ assert.ok(profileA, 'View profile button should still render');
+ assert.equal(profileA.href, "https://rescue.org/");
+ });
+
+ it('drops the rescue-name link when profileUrl is missing and rescueUrl falls back to it (both empty)', () => {
+ window.renderCard({
+ name: "Milo",
+ rescueName: "Second Chance Rescue",
+ rescueUrl: null,
+ profileUrl: "https://rescue.org",
+ imageUrl: "https://image.org/cat.jpg"
+ });
+ const card = document.getElementById("card");
+ const rescueP = card.querySelector('.rescue');
+ assert.equal(rescueP.querySelector('a'), null, 'rescueUrl falling back to profileUrl makes them identical -- no separate link');
+ const profileA = card.querySelector('.profile');
+ assert.ok(profileA);
+ assert.equal(profileA.href, "https://rescue.org/");
+ });
+
+ it('shows both links when rescueUrl and profileUrl genuinely differ', () => {
+ window.renderCard({
+ name: "Milo",
+ rescueName: "Second Chance Rescue",
+ rescueUrl: "https://rescue.org",
+ profileUrl: "https://rescue.rescuegroups.org/animals/detail?AnimalID=123",
+ imageUrl: "https://image.org/cat.jpg"
+ });
+ const card = document.getElementById("card");
+ const rescueA = card.querySelector('.rescue a');
+ assert.ok(rescueA, 'rescue name should still link when the URLs differ');
+ assert.equal(rescueA.href, "https://rescue.org/");
+ const profileA = card.querySelector('.profile');
+ assert.ok(profileA);
+ assert.equal(profileA.href, "https://rescue.rescuegroups.org/animals/detail?AnimalID=123");
+ });
+
+ it('shows the rescue-name link on its own when there is no profileUrl at all', () => {
+ window.renderCard({
+ name: "Milo",
+ rescueName: "Second Chance Rescue",
+ rescueUrl: "https://rescue.org",
+ profileUrl: null,
+ imageUrl: "https://image.org/cat.jpg"
+ });
+ const card = document.getElementById("card");
+ const rescueA = card.querySelector('.rescue a');
+ assert.ok(rescueA);
+ assert.equal(card.querySelector('.profile'), null, 'no View profile button without a profileUrl');
+ });
+ });
+
+ describe('photo load failure (issue #69)', () => {
+ it('shows the generic "photo unavailable" notice when the image fails to load while online', () => {
+ window.renderCard({ name: "Milo", imageUrl: "https://image.org/cat.jpg" });
+ Object.defineProperty(window.navigator, 'onLine', { value: true, configurable: true });
+
+ document.querySelector('.photo').dispatchEvent(new window.Event('error'));
+
+ assert.ok(document.getElementById('notice').textContent.includes('no longer available'));
+ });
+
+ it('shows an offline-specific notice when the image fails to load while offline', () => {
+ window.renderCard({ name: "Milo", imageUrl: "https://image.org/cat.jpg" });
+ Object.defineProperty(window.navigator, 'onLine', { value: false, configurable: true });
+
+ document.querySelector('.photo').dispatchEvent(new window.Event('error'));
+
+ const noticeText = document.getElementById('notice').textContent;
+ assert.ok(noticeText.includes("offline"), `expected an offline-specific notice, got: ${noticeText}`);
+ assert.ok(!noticeText.includes('no longer available'), 'should not show the generic missing-photo message while offline');
+ });
+ });
+
+ describe('Save cats (issue #43)', () => {
+ const cardData = {
+ id: 'cat-1',
+ name: 'Milo',
+ breed: 'Tabby',
+ age: 'Adult',
+ sex: 'Male',
+ rescueName: 'Second Chance Rescue',
+ rescueUrl: 'https://rescue.org',
+ profileUrl: 'https://rescue.org/animals/milo',
+ adoptionFee: '$50',
+ imageUrl: 'https://image.org/cat.jpg',
+ originalImageUrl: 'https://image.org/cat-original.jpg'
+ };
+
+ it('renders unsaved by default when the cat is not in savedCats', async () => {
+ window.renderCard(cardData);
+ await new Promise(r => setTimeout(r, 10));
+
+ const saveBtn = document.querySelector('.save-btn');
+ assert.ok(saveBtn);
+ assert.equal(saveBtn.getAttribute('aria-pressed'), 'false');
+ assert.equal(saveBtn.classList.contains('saved'), false);
+ assert.equal(saveBtn.querySelector('svg').getAttribute('fill'), 'none');
+ });
+
+ it('renders as already-saved when the cat is already in savedCats', async () => {
+ window.chrome.storage.local.get = async () => ({ savedCats: [{ id: 'cat-1', name: 'Milo' }] });
+ window.renderCard(cardData);
+ await new Promise(r => setTimeout(r, 10));
+
+ const saveBtn = document.querySelector('.save-btn');
+ assert.equal(saveBtn.getAttribute('aria-pressed'), 'true');
+ assert.ok(saveBtn.classList.contains('saved'));
+ assert.equal(saveBtn.querySelector('svg').getAttribute('fill'), 'currentColor');
+ });
+
+ it('saves a snapshot of the cat to chrome.storage.local on click', async () => {
+ window.chrome.storage.local.get = async () => ({ savedCats: [] });
+ let setPayload;
+ window.chrome.storage.local.set = async (val) => { setPayload = val; };
+ window.renderCard(cardData);
+ await new Promise(r => setTimeout(r, 10));
+
+ document.querySelector('.save-btn').dispatchEvent(new window.Event('click'));
+ await new Promise(r => setTimeout(r, 10));
+
+ assert.equal(setPayload.savedCats.length, 1);
+ const saved = setPayload.savedCats[0];
+ assert.equal(saved.id, 'cat-1');
+ assert.equal(saved.name, 'Milo');
+ assert.equal(saved.breed, 'Tabby');
+ assert.equal(saved.profileUrl, 'https://rescue.org/animals/milo');
+ assert.equal(saved.originalImageUrl, 'https://image.org/cat-original.jpg');
+ assert.ok(saved.savedAt, 'should record when it was saved');
+
+ const saveBtn = document.querySelector('.save-btn');
+ assert.equal(saveBtn.getAttribute('aria-pressed'), 'true');
+ assert.ok(saveBtn.classList.contains('saved'));
+ });
+
+ it('removes the cat from chrome.storage.local when unsaving', async () => {
+ window.chrome.storage.local.get = async () => ({ savedCats: [{ id: 'cat-1', name: 'Milo' }] });
+ let setPayload;
+ window.chrome.storage.local.set = async (val) => { setPayload = val; };
+ window.renderCard(cardData);
+ await new Promise(r => setTimeout(r, 10));
+
+ document.querySelector('.save-btn').dispatchEvent(new window.Event('click'));
+ await new Promise(r => setTimeout(r, 10));
+
+ assert.equal(setPayload.savedCats.length, 0);
+ const saveBtn = document.querySelector('.save-btn');
+ assert.equal(saveBtn.getAttribute('aria-pressed'), 'false');
+ assert.equal(saveBtn.classList.contains('saved'), false);
+ });
+
+ it('shows a notice and leaves the button state unchanged if storage.set fails', async () => {
+ window.chrome.storage.local.get = async () => ({ savedCats: [] });
+ window.chrome.storage.local.set = async () => { throw new Error('disk full'); };
+ window.console.error = () => {};
+ window.renderCard(cardData);
+ await new Promise(r => setTimeout(r, 10));
+
+ document.querySelector('.save-btn').dispatchEvent(new window.Event('click'));
+ await new Promise(r => setTimeout(r, 10));
+
+ const saveBtn = document.querySelector('.save-btn');
+ assert.equal(saveBtn.getAttribute('aria-pressed'), 'false', 'save should not appear to have succeeded');
+ assert.ok(document.getElementById('notice').textContent.includes("Couldn't update"));
+ });
+
+ describe('header "Saved cats" indicator', () => {
+ it('has no has-saved class when there are no saved cats', async () => {
+ window.chrome.storage.local.get = async () => ({ savedCats: [] });
+ await window.updateSavedHeaderIndicator();
+
+ assert.equal(document.getElementById('saved').classList.contains('has-saved'), false);
+ });
+
+ it('gets the has-saved class once there is at least one saved cat', async () => {
+ window.chrome.storage.local.get = async () => ({ savedCats: [{ id: 'cat-9', name: 'Other' }] });
+ await window.updateSavedHeaderIndicator();
+
+ assert.ok(document.getElementById('saved').classList.contains('has-saved'));
+ });
+
+ it('turns on after saving a cat from the card, and off again after unsaving it', async () => {
+ window.chrome.storage.local.get = async () => ({ savedCats: [] });
+ window.chrome.storage.local.set = async (val) => { window.chrome.storage.local.get = async () => val; };
+ window.renderCard(cardData);
+ await new Promise(r => setTimeout(r, 10));
+
+ document.querySelector('.save-btn').dispatchEvent(new window.Event('click'));
+ await new Promise(r => setTimeout(r, 10));
+ assert.ok(document.getElementById('saved').classList.contains('has-saved'), 'should turn on after saving');
+
+ document.querySelector('.save-btn').dispatchEvent(new window.Event('click'));
+ await new Promise(r => setTimeout(r, 10));
+ assert.equal(document.getElementById('saved').classList.contains('has-saved'), false, 'should turn back off after unsaving the only saved cat');
+ });
+ });
+ });
+
describe('renderCard long-name handling', () => {
it('adds the name-long modifier once the name passes 18 characters', () => {
window.renderCard({ name: "Sir Reginald Fluffington III" });
@@ -624,6 +836,27 @@ describe('newtab.js DOM manipulation', () => {
assert.equal(updateCalled, false);
});
});
+ describe('Saved cats button (issue #43)', () => {
+ it('#saved replaces the current tab with the Saved cats page', () => {
+ let updatedTabId, updatedProps;
+ window.chrome.tabs.update = (tabId, props) => { updatedTabId = tabId; updatedProps = props; };
+
+ document.getElementById("saved").click();
+
+ assert.equal(updatedTabId, 1);
+ assert.equal(updatedProps.url, "chrome-extension://fake-id/extension/saved.html");
+ });
+
+ it('does nothing when there is no active tab to replace', () => {
+ window.chrome.tabs.query = (_query, callback) => callback([]);
+ let updateCalled = false;
+ window.chrome.tabs.update = () => { updateCalled = true; };
+
+ document.getElementById("saved").click();
+
+ assert.equal(updateCalled, false);
+ });
+ });
describe('renderCard location label (normal mode)', () => {
it('shows the saved ZIP as the distance basis when locationLabel is "from "', () => {
window.renderCard({ name: "Milo", distanceMiles: 5.5 }, { locationLabel: "from 97703" });
@@ -1519,6 +1752,18 @@ describe('newtab.js DOM manipulation', () => {
assert.equal(link.textContent, 'zip code');
});
+ it('shows an offline-specific notice (no report-issue/zip links) when refresh fails while offline', async () => {
+ Object.defineProperty(window.navigator, 'onLine', { value: false, configurable: true });
+ window.fetch = async () => { throw new TypeError("Failed to fetch"); };
+ window.console.error = () => {};
+
+ await window.start();
+
+ const notice = document.getElementById("notice");
+ assert.ok(notice.textContent.includes("offline"), `expected an offline-specific notice, got: ${notice.textContent}`);
+ assert.equal(notice.querySelector('.notice-link'), null, 'offline notice should not offer a report-issue or zip-code shortcut');
+ });
+
it('prevents multiple concurrent executions', async () => {
const p1 = window.start();
const p2 = window.start();
@@ -1622,6 +1867,18 @@ describe('newtab.js DOM manipulation', () => {
assert.ok(document.getElementById('notice').textContent.length > 0);
});
+ it('shows an offline-specific notice when the explore fetch fails while offline', async () => {
+ Object.defineProperty(window.navigator, 'onLine', { value: false, configurable: true });
+ window.fetch = async () => { throw new TypeError("Failed to fetch"); };
+ window.console.error = () => {};
+
+ document.getElementById('explore').dispatchEvent(new window.Event('click'));
+ await new Promise(r => setTimeout(r, 10));
+
+ const noticeText = document.getElementById('notice').textContent;
+ assert.ok(noticeText.includes("offline"), `expected an offline-specific notice, got: ${noticeText}`);
+ });
+
it('shows the distance relative to the explored city, not the user, while exploring', async () => {
window.fetch = async () => ({
ok: true,
diff --git a/test/saved.test.js b/test/saved.test.js
new file mode 100644
index 0000000..f9a65d6
--- /dev/null
+++ b/test/saved.test.js
@@ -0,0 +1,212 @@
+import { describe, it, beforeEach } from 'node:test';
+import assert from 'node:assert';
+import { JSDOM } from 'jsdom';
+import fs from 'node:fs';
+import path from 'node:path';
+
+const htmlContent = fs.readFileSync(path.join(process.cwd(), 'extension', 'saved.html'), 'utf-8');
+const jsContent = fs.readFileSync(path.join(process.cwd(), 'extension', 'saved.js'), 'utf-8');
+
+describe('saved.js', () => {
+ let dom;
+ let window;
+ let document;
+ let storedSavedCats;
+
+ function mountWith(savedCats) {
+ storedSavedCats = savedCats;
+ dom = new JSDOM(htmlContent, { runScripts: "dangerously" });
+ window = dom.window;
+ document = window.document;
+
+ window.chrome = {
+ storage: {
+ local: {
+ get: async () => ({ savedCats: storedSavedCats }),
+ set: async (val) => { storedSavedCats = val.savedCats; }
+ }
+ },
+ runtime: { getURL: (path) => `chrome-extension://id/${path}` },
+ tabs: {
+ query: (_query, cb) => cb([]),
+ create: (opts, cb) => { if (cb) cb(); },
+ remove: (_id) => {}
+ }
+ };
+ window.close = () => {};
+
+ const scriptEl = document.createElement('script');
+ scriptEl.textContent = jsContent;
+ document.body.appendChild(scriptEl);
+ }
+
+ beforeEach(() => {
+ mountWith([]);
+ });
+
+ it('shows the empty-state message and no items when nothing is saved', async () => {
+ await new Promise(r => setTimeout(r, 10));
+
+ assert.equal(document.getElementById('saved-empty').hidden, false);
+ assert.equal(document.querySelectorAll('.saved-item').length, 0);
+ });
+
+ it('renders a saved cat with photo, meta, and remove button, and hides the empty state', async () => {
+ mountWith([{
+ id: 'cat-1',
+ name: 'Milo',
+ breed: 'Tabby',
+ age: 'Adult',
+ sex: 'Male',
+ adoptionFee: '$50',
+ imageUrl: 'https://image.org/cat.jpg',
+ rescueName: 'Second Chance Rescue',
+ rescueUrl: 'https://rescue.org',
+ profileUrl: 'https://rescue.org/animals/milo',
+ savedAt: new Date().toISOString()
+ }]);
+ await new Promise(r => setTimeout(r, 10));
+
+ assert.equal(document.getElementById('saved-empty').hidden, true);
+ const item = document.querySelector('.saved-item');
+ assert.ok(item);
+ assert.equal(item.querySelector('.saved-item-photo').src, 'https://image.org/cat.jpg');
+ assert.equal(item.querySelector('.saved-item-name').textContent, 'Milo');
+ assert.equal(item.querySelector('.saved-item-meta').textContent, 'Tabby · Adult · Male · $50');
+ assert.ok(item.querySelector('.saved-item-remove'));
+ });
+
+ describe('lightbox (larger photo on click)', () => {
+ beforeEach(async () => {
+ mountWith([{
+ id: 'cat-1', name: 'Milo', imageUrl: 'https://image.org/cat.jpg',
+ originalImageUrl: 'https://image.org/cat-original.jpg',
+ savedAt: new Date().toISOString()
+ }]);
+ await new Promise(r => setTimeout(r, 10));
+ });
+
+ it('opens the original-resolution photo in an overlay on click', () => {
+ document.querySelector('.saved-item-photo-btn').dispatchEvent(new window.Event('click'));
+
+ const overlay = document.querySelector('.lightbox-overlay');
+ assert.ok(overlay);
+ assert.equal(overlay.querySelector('.lightbox-img').src, 'https://image.org/cat-original.jpg');
+ });
+
+ it('falls back to imageUrl when originalImageUrl is missing', async () => {
+ mountWith([{ id: 'cat-2', name: 'Biscuit', imageUrl: 'https://image.org/biscuit.jpg', savedAt: new Date().toISOString() }]);
+ await new Promise(r => setTimeout(r, 10));
+
+ document.querySelector('.saved-item-photo-btn').dispatchEvent(new window.Event('click'));
+
+ assert.equal(document.querySelector('.lightbox-img').src, 'https://image.org/biscuit.jpg');
+ });
+
+ it('closes when the close button is clicked', () => {
+ document.querySelector('.saved-item-photo-btn').dispatchEvent(new window.Event('click'));
+ document.querySelector('.lightbox-close').dispatchEvent(new window.Event('click'));
+
+ assert.equal(document.querySelector('.lightbox-overlay'), null);
+ });
+
+ it('closes when clicking outside the image (the overlay backdrop)', () => {
+ document.querySelector('.saved-item-photo-btn').dispatchEvent(new window.Event('click'));
+ const overlay = document.querySelector('.lightbox-overlay');
+ overlay.dispatchEvent(new window.Event('click'));
+
+ assert.equal(document.querySelector('.lightbox-overlay'), null);
+ });
+
+ it('does not close when clicking the image itself', () => {
+ document.querySelector('.saved-item-photo-btn').dispatchEvent(new window.Event('click'));
+ const img = document.querySelector('.lightbox-img');
+ const event = new window.Event('click', { bubbles: true });
+ img.dispatchEvent(event);
+
+ assert.ok(document.querySelector('.lightbox-overlay'), 'overlay should still be open');
+ });
+
+ it('closes on Escape', () => {
+ document.querySelector('.saved-item-photo-btn').dispatchEvent(new window.Event('click'));
+ document.dispatchEvent(new window.KeyboardEvent('keydown', { key: 'Escape' }));
+
+ assert.equal(document.querySelector('.lightbox-overlay'), null);
+ });
+ });
+
+ it('shows only one link when rescueUrl and profileUrl match (issue #51 parity)', async () => {
+ mountWith([{
+ id: 'cat-1', name: 'Milo', imageUrl: 'https://image.org/cat.jpg',
+ rescueName: 'Second Chance Rescue', rescueUrl: 'https://rescue.org', profileUrl: 'https://rescue.org',
+ savedAt: new Date().toISOString()
+ }]);
+ await new Promise(r => setTimeout(r, 10));
+
+ const links = document.querySelectorAll('.saved-item-links a');
+ assert.equal(links.length, 1, 'should collapse to a single link when both URLs match');
+ assert.equal(links[0].textContent, 'View profile');
+ });
+
+ it('shows both links when rescueUrl and profileUrl differ', async () => {
+ mountWith([{
+ id: 'cat-1', name: 'Milo', imageUrl: 'https://image.org/cat.jpg',
+ rescueName: 'Second Chance Rescue', rescueUrl: 'https://rescue.org', profileUrl: 'https://rescue.rescuegroups.org/animals/detail?AnimalID=1',
+ savedAt: new Date().toISOString()
+ }]);
+ await new Promise(r => setTimeout(r, 10));
+
+ const links = document.querySelectorAll('.saved-item-links a');
+ assert.equal(links.length, 2);
+ assert.equal(links[0].textContent, 'Second Chance Rescue');
+ assert.equal(links[1].textContent, 'View profile');
+ });
+
+ it('sorts saved cats most-recently-saved first', async () => {
+ mountWith([
+ { id: 'old', name: 'Older', imageUrl: 'https://image.org/a.jpg', savedAt: new Date(Date.now() - 100000).toISOString() },
+ { id: 'new', name: 'Newer', imageUrl: 'https://image.org/b.jpg', savedAt: new Date().toISOString() }
+ ]);
+ await new Promise(r => setTimeout(r, 10));
+
+ const names = [...document.querySelectorAll('.saved-item-name')].map(el => el.textContent);
+ assert.deepEqual(names, ['Newer', 'Older']);
+ });
+
+ it('removes a saved cat from storage and re-renders when Remove is clicked', async () => {
+ mountWith([{ id: 'cat-1', name: 'Milo', imageUrl: 'https://image.org/cat.jpg', savedAt: new Date().toISOString() }]);
+ await new Promise(r => setTimeout(r, 10));
+
+ document.querySelector('.saved-item-remove').dispatchEvent(new window.Event('click'));
+ await new Promise(r => setTimeout(r, 10));
+
+ assert.deepEqual(storedSavedCats, []);
+ assert.equal(document.querySelectorAll('.saved-item').length, 0);
+ assert.equal(document.getElementById('saved-empty').hidden, false);
+ });
+
+ it('replaces the active tab with newtab.html when back-to-tabby is clicked', async () => {
+ await new Promise(r => setTimeout(r, 10));
+ let createArgs;
+ let removedTabId;
+ window.chrome.tabs.query = (_query, cb) => cb([{ id: 7 }]);
+ window.chrome.tabs.create = (opts, cb) => { createArgs = opts; if (cb) cb(); };
+ window.chrome.tabs.remove = (id) => { removedTabId = id; };
+
+ document.getElementById('back-to-tabby').dispatchEvent(new window.Event('click'));
+
+ assert.deepEqual(createArgs, { url: 'chrome-extension://id/extension/newtab.html', active: true });
+ assert.equal(removedTabId, 7);
+ });
+
+ it('falls back to window.close() when there is no active tab to replace', async () => {
+ await new Promise(r => setTimeout(r, 10));
+ window.chrome.tabs.query = (_query, cb) => cb([]);
+ let closeCalled = false;
+ window.close = () => { closeCalled = true; };
+
+ document.getElementById('back-to-tabby').dispatchEvent(new window.Event('click'));
+
+ assert.ok(closeCalled);
+ });
+});
diff --git a/webstore/WEBSTORE.md b/webstore/WEBSTORE.md
index 3fe2292..3cb19cc 100644
--- a/webstore/WEBSTORE.md
+++ b/webstore/WEBSTORE.md
@@ -43,6 +43,7 @@ FEATURES
• One cat, front and center — name, breed, age, sex, adoption fee, and how recently the listing was updated, plus a direct link to the cat's full profile and the rescue organization.
• Adoption-pending and special-needs cats are clearly flagged, so you know a cat's status before reaching out.
• "Explore another area" lets you browse adoptable cats in other major U.S. cities, even if you're not planning to adopt locally.
+• Save a cat with the heart icon to find it again later, even after it's adopted or drops out of your regular feed.
• Fast and quiet — Tabby shows your last cat instantly from a local cache and refreshes in the background, so opening a new tab never feels slow.
HOW TO USE
@@ -106,7 +107,7 @@ All screenshots use the real `extension/newtab.css`/`newtab.js`/`options.js` unm
| Permission | Type | Justification |
|------------|------|----------------|
-| `storage` | permissions | The storage permission lets Tabby save the user's chosen location (ZIP code or geolocation coordinates) and the most recently fetched batch of adoptable-cat listings locally on the device, via `chrome.storage.local` (never `chrome.storage.sync`). This is what lets the new tab page render instantly from a local cache instead of re-fetching from the network on every tab open, and lets Tabby remember which cats the user has already seen so repeat refreshes surface new cats first. No data stored via this permission is synced to Google's servers or shared with any third party. |
+| `storage` | permissions | The storage permission lets Tabby save the user's chosen location (ZIP code or geolocation coordinates), the most recently fetched batch of adoptable-cat listings, and any cats the user has explicitly saved via the heart icon, all locally on the device via `chrome.storage.local` (never `chrome.storage.sync`). This is what lets the new tab page render instantly from a local cache instead of re-fetching from the network on every tab open, lets Tabby remember which cats the user has already seen so repeat refreshes surface new cats first, and lets a saved cat's card persist for later even if it's later adopted or removed from the live feed. No data stored via this permission is synced to Google's servers or shared with any third party. |
| `geolocation` | permissions | The geolocation permission is used only when the user explicitly clicks "Use my location," either on first run or in Settings. It triggers Chrome's native location permission prompt; if the user allows it, Tabby reads the device's coordinates once to search RescueGroups.org for adoptable cats near that location, sending only those coordinates (never any other browser or device data) to Tabby's own backend to perform the search. Users can decline and enter a five-digit ZIP code instead — geolocation is never required for the extension to work. |