feat: add reminders, auto-delete, cross-device sync, dark mode, and rediscovery spotlight

- Rename extension from Shelf to "Read it later"
- Add reminder settings: highlight and pin overdue unarchived items
- Add auto-delete with a hard safety rule requiring its period to exceed the reminder period
- Add cross-device sync via chrome.storage.sync (lightweight per-item metadata, no thumbnails)
- Add light/dark/system theme support
- Fix list-view Archive/Delete button alignment (card-perf wasn't filling the row)
- Add "Worth a second look" spotlight: proactively resurfaces old, forgotten items with automatic backoff so repeatedly-ignored items are shown less often, never permanently
This commit is contained in:
2026-07-13 20:49:05 +10:00
parent d627e6bb44
commit c5bd7e38bf
7 changed files with 847 additions and 32 deletions
+57 -8
View File
@@ -1,4 +1,4 @@
# Shelf — Read Later
# Read it later
A new-tab page that shows everything you've saved to read later, as a
sortable grid or list. No account, no server — everything lives in your
@@ -26,12 +26,48 @@ Thumbnails are captured automatically when you save the *current* page
(a screenshot of what's on screen). Links saved via right-click show a
favicon instead, since the browser hasn't loaded that page yet.
## Reminders and auto-delete
The gear icon opens a settings panel (also home to the light/dark/system
theme picker) with two optional timers, both off by default:
- **Remind me after** — items older than this get pinned to the top of the
shelf and highlighted, so they don't get buried.
- **Delete unarchived items after** — silently removes items you never
came back to. Archiving an item takes it out of consideration entirely.
The delete period must be longer than the reminder period — enforced both
in the settings panel (an inline error if you try to set it shorter) and as
a hard rule in the deletion logic itself, so you're always warned before
anything is removed. Auto-delete runs on an hourly background alarm (so it
still works even if you never open the new tab page), and again whenever
the shelf page is open; when the shelf page does the deleting, it shows a
courtesy toast with an **Undo**.
## Worth a second look
Above the shelf, a small strip proactively surfaces 23 old, forgotten,
unarchived items each time you open a new tab — on by default, toggleable
in the settings panel. Unlike a notification, it doesn't nag: the more
times an item gets shown without you opening/archiving/deleting it, the
less often it's picked again (backing off from every few days out to every
couple of months), so nothing gets shown forever, but nothing gets shown
*so* rarely that it's effectively forgotten again either. Archiving or
deleting an item removes it from the strip immediately with no replacement
until the next refresh — no whack-a-mole.
"Old enough to matter" uses the same period as the reminder setting above
when reminders are on, or a 14-day default when they're off. It's fine (and
expected) for the same item to show up both pinned/highlighted in the main
shelf *and* in this strip at once — they're not trying to be non-overlapping,
just two different nudges.
## Install — Chrome / Edge (unpacked)
1. Go to `chrome://extensions` (or `edge://extensions`).
2. Turn on **Developer mode** (top right).
3. Click **Load unpacked** and select this folder.
4. Open a new tab — you should see the Shelf page.
4. Open a new tab — you should see the Read it later page.
## Install — Firefox (temporary, for testing)
@@ -47,13 +83,26 @@ favicon instead, since the browser hasn't loaded that page yet.
> [Developer Edition / Nightly build](https://www.mozilla.org/firefox/developer/)
> with `xpinstall.signatures.required` set to `false`.
## Syncing across devices
Read it later keeps full items (including screenshot thumbnails) in
`chrome.storage.local`, and also mirrors a lightweight copy of each item
(url, title, domain, favicon, dates, archived state, spotlight backoff
counters — no images) to
`chrome.storage.sync`, one entry per item so each stays well under the
8KB-per-item sync limit. On another device signed into the same browser
account, opening the shelf pulls those in automatically; thumbnails just
aren't part of what syncs, so items saved elsewhere show a favicon instead
until re-saved on that device. Archiving and deleting also sync. This is
all best-effort — offline, not signed in, or over the (~100KB / 512-item)
sync quota just means that device keeps working from local storage alone.
Note: `chrome.storage.sync` needs a stable extension ID to work, which
only applies to a permanently installed, signed add-on — not a Firefox
"temporary add-on" loaded via `about:debugging` (see install notes above),
which won't persist sync identity across restarts.
## Notes / things you might want to change
- Storage uses `chrome.storage.local`, so saved links stay on the device
you saved them on. Swapping the `chrome.storage.local` calls for
`chrome.storage.sync` in `background.js` and `newtab.js` would sync
small link lists across your signed-in browsers, but `sync` storage
has a much smaller quota (~100KB) and doesn't handle thumbnails well —
not recommended once you have images in the mix.
- Icons in `icons/` are placeholders — swap them for your own artwork
any time; sizes 16/32/48/128 are already wired up in `manifest.json`.
+50 -4
View File
@@ -1,8 +1,17 @@
// Shelf — background service worker
// Read it later — background service worker
// Works under both the `chrome` namespace (Chrome/Edge) and Firefox's
// chrome.* alias for WebExtensions.
// Chrome loads only "service_worker" from the manifest (a Worker context, so
// importScripts works); Firefox loads the "scripts" array instead, which
// already includes sync.js ahead of this file in the same global scope.
if (typeof importScripts === "function") {
importScripts("sync.js");
}
const STORAGE_KEY = "shelfItems";
const PREFS_KEY = "shelfPrefs";
const AUTO_DELETE_ALARM = "autoDeleteSweep";
function domainOf(url) {
try {
@@ -21,25 +30,51 @@ async function setItems(items) {
await chrome.storage.local.set({ [STORAGE_KEY]: items });
}
async function getPrefs() {
const data = await chrome.storage.local.get(PREFS_KEY);
return data[PREFS_KEY] || {};
}
// Runs on an hourly alarm so overdue items get cleaned up even when the
// shelf page is never opened. Silent — no one's necessarily watching, so
// unlike the newtab-page sweep there's no toast/undo here; the newtab page
// runs its own courtesy-toast sweep whenever it's open.
async function sweepAutoDelete() {
const prefs = await getPrefs();
if (!prefs.autoDeleteEnabled) return;
const items = await getItems();
const remaining = items.filter((item) => !isOverdueForDeletion(item, prefs));
if (remaining.length === items.length) return;
const merged = typeof reconcileWithSync === "function" ? await reconcileWithSync(remaining) : remaining;
await setItems(merged);
}
async function addItem({ url, title, favIconUrl, image }) {
if (!url) return;
const items = await getItems();
let items = await getItems();
// De-dupe: if already saved, just bump it to the top and refresh metadata.
const existingIndex = items.findIndex((i) => i.url === url);
const now = Date.now();
const entry = {
id: existingIndex >= 0 ? items[existingIndex].id : `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
id: existingIndex >= 0 ? items[existingIndex].id : `${now}-${Math.random().toString(36).slice(2, 8)}`,
url,
title: title || url,
domain: domainOf(url),
favicon: favIconUrl || "",
image: image || (existingIndex >= 0 ? items[existingIndex].image : ""),
dateAdded: Date.now(),
dateAdded: now,
archived: false,
updatedAt: now,
surfaceCount: existingIndex >= 0 ? items[existingIndex].surfaceCount || 0 : 0,
lastSurfacedAt: existingIndex >= 0 ? items[existingIndex].lastSurfacedAt || 0 : 0,
};
if (existingIndex >= 0) items.splice(existingIndex, 1);
items.unshift(entry);
items = typeof reconcileWithSync === "function" ? await reconcileWithSync(items) : items;
await setItems(items);
return entry;
}
@@ -111,3 +146,14 @@ chrome.contextMenus.onClicked.addListener(async (info, tab) => {
if (tab && tab.id) flashBadge(tab.id, "✓");
}
});
// --- Auto-delete: hourly sweep, independent of whether the shelf is open ---
// chrome.alarms.create is idempotent (re-arming an existing alarm just
// resets it), so it's safe to call unconditionally every time this script
// runs rather than only from onInstalled.
chrome.alarms.create(AUTO_DELETE_ALARM, { periodInMinutes: 60 });
sweepAutoDelete();
chrome.alarms.onAlarm.addListener((alarm) => {
if (alarm.name === AUTO_DELETE_ALARM) sweepAutoDelete();
});
+4 -4
View File
@@ -1,6 +1,6 @@
{
"manifest_version": 3,
"name": "Shelf — Read Later",
"name": "Read it later",
"version": "1.0.0",
"description": "Save pages and links for later, and see them all as a sortable shelf every time you open a new tab.",
"icons": {
@@ -9,13 +9,13 @@
"48": "icons/icon48.png",
"128": "icons/icon128.png"
},
"permissions": ["storage", "contextMenus", "activeTab", "unlimitedStorage"],
"permissions": ["storage", "contextMenus", "activeTab", "unlimitedStorage", "alarms"],
"background": {
"service_worker": "background.js",
"scripts": ["background.js"]
"scripts": ["sync.js", "background.js"]
},
"action": {
"default_title": "Save this page to Shelf",
"default_title": "Save this page to Read it later",
"default_icon": {
"16": "icons/icon16.png",
"32": "icons/icon32.png",
+199 -6
View File
@@ -4,10 +4,66 @@
--ink: #211E19;
--muted: #756E60;
--hairline: #DBD6C7;
--hairline-strong: #C9C2AE;
--clay: #C47A4A;
--clay-deep: #A8623A;
--moss: #55684B;
--accent-soft: #FBEFE6;
--muted-soft: #E6E2D3;
--thumb-1: #E4DFD0;
--thumb-2: #D8D2BF;
--danger: #A8412D;
--danger-soft: #E3B7A9;
--toast-accent: #E2905F;
--shadow: 0 1px 2px rgba(33, 30, 25, 0.06), 0 6px 16px rgba(33, 30, 25, 0.05);
color-scheme: light;
}
/* Dark palette: applied when the user forces dark mode ([data-theme="dark"]),
or leaves it on "system" (anything but [data-theme="light"]) and the OS
prefers dark. */
@media (prefers-color-scheme: dark) {
:root:not([data-theme="light"]) {
--paper: #1B1815;
--surface: #242019;
--ink: #EEE8DC;
--muted: #A89E8C;
--hairline: #3A342A;
--hairline-strong: #4A4234;
--clay: #E2905F;
--clay-deep: #F0AC80;
--moss: #8CA378;
--accent-soft: rgba(224, 144, 95, 0.16);
--muted-soft: #332D22;
--thumb-1: #2C2720;
--thumb-2: #221E18;
--danger: #E2836C;
--danger-soft: rgba(226, 131, 108, 0.35);
--toast-accent: #A8623A;
--shadow: 0 1px 2px rgba(0, 0, 0, 0.35), 0 6px 20px rgba(0, 0, 0, 0.4);
color-scheme: dark;
}
}
:root[data-theme="dark"] {
--paper: #1B1815;
--surface: #242019;
--ink: #EEE8DC;
--muted: #A89E8C;
--hairline: #3A342A;
--hairline-strong: #4A4234;
--clay: #E2905F;
--clay-deep: #F0AC80;
--moss: #8CA378;
--accent-soft: rgba(224, 144, 95, 0.16);
--muted-soft: #332D22;
--thumb-1: #2C2720;
--thumb-2: #221E18;
--danger: #E2836C;
--danger-soft: rgba(226, 131, 108, 0.35);
--toast-accent: #A8623A;
--shadow: 0 1px 2px rgba(0, 0, 0, 0.35), 0 6px 20px rgba(0, 0, 0, 0.4);
color-scheme: dark;
}
* { box-sizing: border-box; }
@@ -111,7 +167,7 @@ select#sortSelect {
.toggle-btn[aria-pressed="true"] {
color: var(--clay-deep);
border-color: var(--clay);
background: #FBEFE6;
background: var(--accent-soft);
}
.toggle-btn:focus-visible, .ghost-btn:focus-visible, select:focus-visible {
outline: 2px solid var(--clay);
@@ -121,7 +177,86 @@ select#sortSelect {
.ghost-btn.active {
color: var(--clay-deep);
border-color: var(--clay);
background: #FBEFE6;
background: var(--accent-soft);
}
/* --- Settings panel --- */
.settings-wrap {
position: relative;
display: inline-block;
}
.settings-panel {
display: block;
position: absolute;
top: calc(100% + 6px);
right: 0;
left: auto;
z-index: 20;
width: 220px;
background: var(--surface);
border: 1px solid var(--hairline);
border-radius: 10px;
box-shadow: var(--shadow);
padding: 14px;
}
.settings-panel[hidden] {
display: none;
}
.settings-row {
display: flex;
align-items: center;
gap: 8px;
font-size: 13px;
color: var(--ink);
}
.settings-row + .settings-row {
margin-top: 10px;
}
.settings-period input[type="number"] {
width: 64px;
padding: 6px 8px;
border-radius: 6px;
border: 1px solid var(--hairline);
background: var(--paper);
font: inherit;
color: var(--ink);
}
.settings-period select {
flex: 1;
padding: 6px 8px;
border-radius: 6px;
border: 1px solid var(--hairline);
background: var(--paper);
font: inherit;
color: var(--ink);
}
.settings-theme {
justify-content: space-between;
}
.settings-theme select {
padding: 6px 8px;
border-radius: 6px;
border: 1px solid var(--hairline);
background: var(--paper);
font: inherit;
color: var(--ink);
}
.settings-divider {
border: none;
border-top: 1px solid var(--hairline);
margin: 12px 0;
}
.settings-hint {
margin: 10px 0 0;
font-size: 11.5px;
color: var(--muted);
line-height: 1.4;
}
.settings-error {
margin: 6px 0 0;
font-size: 11.5px;
color: var(--danger);
line-height: 1.4;
}
/* --- Shelf: grid view (library index cards) --- */
@@ -146,9 +281,25 @@ select#sortSelect {
box-shadow: 0 2px 4px rgba(33,30,25,0.08), 0 10px 22px rgba(33,30,25,0.08);
}
.card-due {
border-color: var(--clay);
box-shadow: 0 0 0 2px rgba(196, 122, 74, 0.25), var(--shadow);
}
.due-badge {
font-size: 10px;
padding: 2px 6px;
border-radius: 999px;
background: var(--accent-soft);
color: var(--clay-deep);
font-family: ui-monospace, monospace;
text-transform: uppercase;
letter-spacing: 0.04em;
}
.card-thumb {
height: 120px;
background: linear-gradient(135deg, #E4DFD0, #D8D2BF);
background: linear-gradient(135deg, var(--thumb-1), var(--thumb-2));
display: flex;
align-items: center;
justify-content: center;
@@ -215,8 +366,8 @@ select#sortSelect {
cursor: pointer;
font-family: inherit;
}
.icon-btn:hover { color: var(--ink); border-color: #C9C2AE; }
.icon-btn.danger:hover { color: #A8412D; border-color: #E3B7A9; }
.icon-btn:hover { color: var(--ink); border-color: var(--hairline-strong); }
.icon-btn.danger:hover { color: var(--danger); border-color: var(--danger-soft); }
/* --- Shelf: list view (bookshelf spines) --- */
.shelf[data-view="list"] {
@@ -239,6 +390,9 @@ select#sortSelect {
flex-shrink: 0;
}
.shelf[data-view="list"] .card-perf {
display: flex;
flex: 1;
min-width: 0;
border-top: none;
border-left: 1px dashed var(--hairline);
}
@@ -264,13 +418,42 @@ select#sortSelect {
}
.shelf[data-view="list"] .card-actions {
margin-top: 0;
margin-left: auto;
flex-shrink: 0;
}
/* --- Spotlight: "Worth a second look" strip --- */
.spotlight {
margin-bottom: 28px;
}
.spotlight-heading {
font-family: Georgia, serif;
font-size: 13px;
font-weight: 600;
color: var(--muted);
margin: 0 0 10px;
text-transform: uppercase;
letter-spacing: 0.06em;
}
.spotlight-strip {
display: flex;
gap: 14px;
overflow-x: auto;
padding-bottom: 4px;
}
.spotlight-strip .card {
flex: 0 0 200px;
width: 200px;
}
.spotlight-strip .card-thumb {
height: 90px;
}
.archived-badge {
font-size: 10px;
padding: 2px 6px;
border-radius: 999px;
background: #E6E2D3;
background: var(--muted-soft);
color: var(--muted);
font-family: ui-monospace, monospace;
text-transform: uppercase;
@@ -304,6 +487,15 @@ select#sortSelect {
font-size: 13px;
box-shadow: var(--shadow);
}
.toast-action {
margin-left: 8px;
background: none;
border: none;
color: var(--toast-accent);
text-decoration: underline;
cursor: pointer;
font: inherit;
}
@media (prefers-reduced-motion: reduce) {
.card { transition: none; }
@@ -312,4 +504,5 @@ select#sortSelect {
@media (max-width: 560px) {
.page { padding: 32px 16px 60px; }
.masthead h1 { font-size: 30px; }
.spotlight-strip .card { flex-basis: 160px; width: 160px; }
}
+64 -3
View File
@@ -2,7 +2,7 @@
<html lang="en">
<head>
<meta charset="UTF-8" />
<title>Shelf</title>
<title>Read it later</title>
<link rel="stylesheet" href="newtab.css" />
</head>
<body>
@@ -10,7 +10,7 @@
<header class="masthead">
<div class="masthead-title">
<span class="mark" aria-hidden="true">🔖</span>
<h1>Shelf</h1>
<h1>Read it later</h1>
</div>
<p class="tally"><span id="unreadCount">0</span> saved for later<span id="archivedNote"></span></p>
</header>
@@ -34,20 +34,81 @@
</div>
<button id="showArchivedBtn" class="ghost-btn">Show archived</button>
<div class="settings-wrap">
<button id="settingsBtn" class="toggle-btn" title="Settings" aria-haspopup="true" aria-expanded="false"></button>
<div id="settingsPanel" class="settings-panel" hidden>
<div class="settings-row settings-theme">
<label for="themeSelect">Theme</label>
<select id="themeSelect">
<option value="system">System</option>
<option value="light">Light</option>
<option value="dark">Dark</option>
</select>
</div>
<hr class="settings-divider" />
<label class="settings-row">
<input type="checkbox" id="reminderEnabled" />
Remind me after
</label>
<div class="settings-row settings-period">
<input type="number" id="reminderAmount" min="1" max="999" value="7" />
<select id="reminderUnit">
<option value="days">days</option>
<option value="weeks">weeks</option>
<option value="months">months</option>
</select>
</div>
<p class="settings-hint">Overdue items are pinned to the top and highlighted.</p>
<hr class="settings-divider" />
<label class="settings-row">
<input type="checkbox" id="autoDeleteEnabled" />
Delete unarchived items after
</label>
<div class="settings-row settings-period">
<input type="number" id="autoDeleteAmount" min="1" max="999" value="30" />
<select id="autoDeleteUnit">
<option value="days">days</option>
<option value="weeks">weeks</option>
<option value="months">months</option>
</select>
</div>
<p class="settings-hint">Archive anything you want to keep — this only removes items you never got back to.</p>
<p class="settings-error" id="autoDeleteError" hidden>Must be longer than the reminder period above, so you're always warned first.</p>
<hr class="settings-divider" />
<label class="settings-row">
<input type="checkbox" id="spotlightEnabled" />
Worth a second look
</label>
<p class="settings-hint">Resurfaces a few old, forgotten items each time you open a new tab. The more you pass on one, the less often it comes back.</p>
</div>
</div>
</div>
<section id="spotlight" class="spotlight" hidden aria-label="Worth a second look">
<h2 class="spotlight-heading">Worth a second look</h2>
<div id="spotlightStrip" class="spotlight-strip"></div>
</section>
<main id="shelf" class="shelf" data-view="grid">
<!-- items injected here -->
</main>
<div id="emptyState" class="empty-state" hidden>
<p class="empty-title">Your shelf is empty.</p>
<p class="empty-sub">Click the Shelf icon in your toolbar, or right-click any page or link, to save something for later.</p>
<p class="empty-sub">Click the Read it later icon in your toolbar, or right-click any page or link, to save something for later.</p>
</div>
</div>
<div id="toast" class="toast" hidden></div>
<script src="sync.js"></script>
<script src="newtab.js"></script>
</body>
</html>
+268 -7
View File
@@ -11,11 +11,48 @@ const showArchivedBtn = document.getElementById("showArchivedBtn");
const unreadCountEl = document.getElementById("unreadCount");
const archivedNoteEl = document.getElementById("archivedNote");
const toastEl = document.getElementById("toast");
const settingsBtn = document.getElementById("settingsBtn");
const settingsPanel = document.getElementById("settingsPanel");
const reminderEnabledEl = document.getElementById("reminderEnabled");
const reminderAmountEl = document.getElementById("reminderAmount");
const reminderUnitEl = document.getElementById("reminderUnit");
const themeSelectEl = document.getElementById("themeSelect");
const autoDeleteEnabledEl = document.getElementById("autoDeleteEnabled");
const autoDeleteAmountEl = document.getElementById("autoDeleteAmount");
const autoDeleteUnitEl = document.getElementById("autoDeleteUnit");
const autoDeleteErrorEl = document.getElementById("autoDeleteError");
const spotlightSectionEl = document.getElementById("spotlight");
const spotlightStripEl = document.getElementById("spotlightStrip");
const spotlightEnabledEl = document.getElementById("spotlightEnabled");
let items = [];
let prefs = { view: "grid", sort: "newest", showArchived: false };
let prefs = {
view: "grid",
sort: "newest",
showArchived: false,
reminderEnabled: false,
reminderAmount: 7,
reminderUnit: "days",
theme: "system",
autoDeleteEnabled: false,
autoDeleteAmount: 30,
autoDeleteUnit: "days",
spotlightEnabled: true,
};
let toastTimer = null;
const SPOTLIGHT_COUNT = 3;
const SPOTLIGHT_COOLDOWN_MS = 60 * 60 * 1000;
const SPOTLIGHT_STATE_KEY = "shelfSpotlightState";
let spotlightState = { lastRunAt: 0, itemIds: [] };
function isDue(item) {
if (!prefs.reminderEnabled || item.archived) return false;
const ms = periodMs(prefs.reminderAmount, prefs.reminderUnit);
if (!ms) return false;
return Date.now() - item.dateAdded >= ms;
}
// A short, muted palette for the bookshelf "spine" color, chosen per domain
// so the same site always lands on the same color.
const SPINE_COLORS = ["#C47A4A", "#55684B", "#5A6E8C", "#8C5A6E", "#8C7A4A", "#4A7C8C"];
@@ -39,6 +76,8 @@ async function loadAll() {
const data = await chrome.storage.local.get([STORAGE_KEY, PREFS_KEY]);
items = data[STORAGE_KEY] || [];
prefs = { ...prefs, ...(data[PREFS_KEY] || {}) };
items = await reconcileWithSync(items);
await saveItems();
}
async function savePrefs() {
@@ -49,13 +88,24 @@ async function saveItems() {
await chrome.storage.local.set({ [STORAGE_KEY]: items });
}
// Kept out of shelfPrefs deliberately — this is derived runtime state (which
// items were last shown, and when), not a user-configurable setting.
async function loadSpotlightState() {
const data = await chrome.storage.local.get(SPOTLIGHT_STATE_KEY);
return data[SPOTLIGHT_STATE_KEY] || { lastRunAt: 0, itemIds: [] };
}
async function saveSpotlightState(state) {
await chrome.storage.local.set({ [SPOTLIGHT_STATE_KEY]: state });
}
function showToast(msg, actionLabel, actionFn) {
clearTimeout(toastTimer);
toastEl.textContent = msg + " ";
if (actionLabel && actionFn) {
const btn = document.createElement("button");
btn.textContent = actionLabel;
btn.style.cssText = "margin-left:8px;background:none;border:none;color:#C47A4A;text-decoration:underline;cursor:pointer;font:inherit;";
btn.className = "toast-action";
btn.onclick = () => {
actionFn();
toastEl.hidden = true;
@@ -67,6 +117,9 @@ function showToast(msg, actionLabel, actionFn) {
}
function applyViewButtons() {
document.documentElement.dataset.theme = prefs.theme;
themeSelectEl.value = prefs.theme;
const isGrid = prefs.view === "grid";
shelfEl.dataset.view = prefs.view;
viewGridBtn.setAttribute("aria-pressed", String(isGrid));
@@ -74,6 +127,27 @@ function applyViewButtons() {
sortSelectEl.value = prefs.sort;
showArchivedBtn.textContent = prefs.showArchived ? "Hide archived" : "Show archived";
showArchivedBtn.classList.toggle("active", prefs.showArchived);
reminderEnabledEl.checked = prefs.reminderEnabled;
reminderAmountEl.value = prefs.reminderAmount;
reminderUnitEl.value = prefs.reminderUnit;
reminderAmountEl.disabled = !prefs.reminderEnabled;
reminderUnitEl.disabled = !prefs.reminderEnabled;
autoDeleteEnabledEl.checked = prefs.autoDeleteEnabled;
autoDeleteAmountEl.value = prefs.autoDeleteAmount;
autoDeleteUnitEl.value = prefs.autoDeleteUnit;
autoDeleteAmountEl.disabled = !prefs.autoDeleteEnabled;
autoDeleteUnitEl.disabled = !prefs.autoDeleteEnabled;
const autoDeleteValid = periodMs(prefs.autoDeleteAmount, prefs.autoDeleteUnit) > periodMs(prefs.reminderAmount, prefs.reminderUnit);
autoDeleteErrorEl.hidden = autoDeleteValid;
spotlightEnabledEl.checked = prefs.spotlightEnabled;
settingsBtn.classList.toggle(
"active",
prefs.reminderEnabled || (prefs.autoDeleteEnabled && autoDeleteValid) || prefs.spotlightEnabled
);
}
function faviconOrInitial(item) {
@@ -95,12 +169,13 @@ function initialBadge(item) {
return span;
}
function buildCard(item) {
function buildCard(item, view = prefs.view) {
const card = document.createElement("div");
card.className = "card";
if (isDue(item)) card.classList.add("card-due");
card.dataset.id = item.id;
if (prefs.view === "list") {
if (view === "list") {
const spine = document.createElement("div");
spine.className = "spine";
spine.style.background = spineColorFor(item.domain);
@@ -109,7 +184,7 @@ function buildCard(item) {
const thumb = document.createElement("div");
thumb.className = "card-thumb";
if (prefs.view === "grid" && !item.image) {
if (view === "grid" && !item.image) {
thumb.style.background = `linear-gradient(135deg, ${spineColorFor(item.domain)}33, ${spineColorFor(item.domain)}11)`;
}
if (item.image) {
@@ -144,6 +219,12 @@ function buildCard(item) {
const dateSpan = document.createElement("span");
dateSpan.textContent = timeAgo(item.dateAdded);
meta.appendChild(dateSpan);
if (isDue(item)) {
const badge = document.createElement("span");
badge.className = "due-badge";
badge.textContent = "due";
meta.appendChild(badge);
}
if (item.archived) {
const badge = document.createElement("span");
badge.className = "archived-badge";
@@ -152,7 +233,7 @@ function buildCard(item) {
}
body.appendChild(meta);
if (prefs.view === "grid") {
if (view === "grid") {
const actions = document.createElement("div");
actions.className = "card-actions";
actions.appendChild(makeActionBtn(item));
@@ -163,7 +244,7 @@ function buildCard(item) {
perf.appendChild(body);
card.appendChild(perf);
if (prefs.view === "list") {
if (view === "list") {
const actions = document.createElement("div");
actions.className = "card-actions";
actions.appendChild(makeActionBtn(item));
@@ -205,6 +286,8 @@ async function toggleArchive(id) {
const it = items.find((i) => i.id === id);
if (!it) return;
it.archived = !it.archived;
it.updatedAt = Date.now();
items = await reconcileWithSync(items);
await saveItems();
render();
}
@@ -213,15 +296,90 @@ async function deleteItem(id) {
const idx = items.findIndex((i) => i.id === id);
if (idx === -1) return;
const [removed] = items.splice(idx, 1);
items = await reconcileWithSync(items);
await saveItems();
render();
showToast("Removed from shelf.", "Undo", async () => {
removed.updatedAt = Date.now();
items.splice(idx, 0, removed);
items = await reconcileWithSync(items);
await saveItems();
render();
});
}
// Also run by background.js on an hourly alarm so items get cleaned up even
// when this page isn't open; this copy runs whenever the shelf is open (on
// load, and periodically after) and — unlike the background sweep — surfaces
// a courtesy toast with an Undo, since a human is actually here to see it.
async function runAutoDeleteSweep() {
if (!prefs.autoDeleteEnabled) return;
const removed = items.filter((item) => isOverdueForDeletion(item, prefs));
if (!removed.length) return;
items = items.filter((item) => !isOverdueForDeletion(item, prefs));
items = await reconcileWithSync(items);
await saveItems();
render();
const count = removed.length;
showToast(`Auto-deleted ${count} item${count > 1 ? "s" : ""} you never got back to.`, "Undo", async () => {
const now = Date.now();
removed.forEach((item) => { item.updatedAt = now; });
items.push(...removed);
items = await reconcileWithSync(items);
await saveItems();
render();
});
}
// Picks a fresh "Worth a second look" selection at most once per cooldown
// window, so opening several new tabs in quick succession (normal browsing)
// doesn't get miscounted as repeated views and falsely inflate backoff.
async function refreshSpotlightSelection() {
if (!prefs.spotlightEnabled) return;
spotlightState = await loadSpotlightState();
const now = Date.now();
if (spotlightState.itemIds.length && now - (spotlightState.lastRunAt || 0) < SPOTLIGHT_COOLDOWN_MS) {
return;
}
const picked = pickSpotlightItems(items, prefs, SPOTLIGHT_COUNT);
picked.forEach((item) => {
item.surfaceCount = (item.surfaceCount || 0) + 1;
item.lastSurfacedAt = now;
item.updatedAt = now;
});
if (picked.length) {
items = await reconcileWithSync(items);
await saveItems();
}
spotlightState = { lastRunAt: now, itemIds: picked.map((i) => i.id) };
await saveSpotlightState(spotlightState);
}
// Pure projection of the current selection against live item state — no
// selection logic here, so archiving/deleting a spotlighted card just makes
// it disappear (via the normal render() path) with no replacement sliding
// in until the next cooldown-triggered refresh.
function renderSpotlight() {
if (!prefs.spotlightEnabled || searchEl.value.trim()) {
spotlightSectionEl.hidden = true;
return;
}
const visible = spotlightState.itemIds
.map((id) => items.find((i) => i.id === id))
.filter((item) => item && !item.archived);
spotlightStripEl.innerHTML = "";
visible.forEach((item) => spotlightStripEl.appendChild(buildCard(item, "grid")));
spotlightSectionEl.hidden = visible.length === 0;
}
function getFilteredSorted() {
const query = searchEl.value.trim().toLowerCase();
let list = items.filter((i) => (prefs.showArchived ? true : !i.archived));
@@ -242,6 +400,13 @@ function getFilteredSorted() {
domain: (a, b) => (a.domain || "").localeCompare(b.domain || ""),
};
list.sort(sorters[prefs.sort] || sorters.newest);
if (prefs.reminderEnabled) {
const due = list.filter(isDue);
const rest = list.filter((i) => !isDue(i));
list = [...due, ...rest];
}
return list;
}
@@ -259,6 +424,8 @@ function render() {
emptyStateEl.hidden = items.length > 0;
shelfEl.hidden = items.length === 0;
renderSpotlight();
}
// --- Event wiring ---
@@ -287,6 +454,82 @@ showArchivedBtn.addEventListener("click", async () => {
render();
});
function closeSettingsPanel() {
settingsPanel.hidden = true;
settingsBtn.setAttribute("aria-expanded", "false");
}
settingsBtn.addEventListener("click", (e) => {
e.stopPropagation();
const isOpen = !settingsPanel.hidden;
if (isOpen) {
closeSettingsPanel();
} else {
settingsPanel.hidden = false;
settingsBtn.setAttribute("aria-expanded", "true");
}
});
document.addEventListener("click", (e) => {
if (!settingsPanel.hidden && !e.target.closest(".settings-wrap")) closeSettingsPanel();
});
document.addEventListener("keydown", (e) => {
if (e.key === "Escape" && !settingsPanel.hidden) closeSettingsPanel();
});
reminderEnabledEl.addEventListener("change", async () => {
prefs.reminderEnabled = reminderEnabledEl.checked;
await savePrefs();
render();
});
reminderAmountEl.addEventListener("change", async () => {
const val = Math.max(1, parseInt(reminderAmountEl.value, 10) || 1);
reminderAmountEl.value = val;
prefs.reminderAmount = val;
await savePrefs();
render();
});
reminderUnitEl.addEventListener("change", async () => {
prefs.reminderUnit = reminderUnitEl.value;
await savePrefs();
render();
});
themeSelectEl.addEventListener("change", async () => {
prefs.theme = themeSelectEl.value;
await savePrefs();
render();
});
autoDeleteEnabledEl.addEventListener("change", async () => {
prefs.autoDeleteEnabled = autoDeleteEnabledEl.checked;
await savePrefs();
render();
});
autoDeleteAmountEl.addEventListener("change", async () => {
const val = Math.max(1, parseInt(autoDeleteAmountEl.value, 10) || 1);
autoDeleteAmountEl.value = val;
prefs.autoDeleteAmount = val;
await savePrefs();
render();
});
autoDeleteUnitEl.addEventListener("change", async () => {
prefs.autoDeleteUnit = autoDeleteUnitEl.value;
await savePrefs();
render();
});
spotlightEnabledEl.addEventListener("change", async () => {
prefs.spotlightEnabled = spotlightEnabledEl.checked;
await savePrefs();
renderSpotlight();
});
// Live-update if items change from another tab / the background worker.
chrome.storage.onChanged.addListener((changes, area) => {
if (area !== "local") return;
@@ -296,7 +539,25 @@ chrome.storage.onChanged.addListener((changes, area) => {
}
});
// Live-update when another device pushes changes via chrome.storage.sync.
let reconcilingSync = false;
chrome.storage.onChanged.addListener(async (changes, area) => {
if (area !== "sync" || reconcilingSync) return;
reconcilingSync = true;
try {
items = await reconcileWithSync(items);
await saveItems();
render();
} finally {
reconcilingSync = false;
}
});
(async function init() {
await loadAll();
render();
await runAutoDeleteSweep();
await refreshSpotlightSelection();
renderSpotlight();
setInterval(runAutoDeleteSweep, 5 * 60 * 1000);
})();
+205
View File
@@ -0,0 +1,205 @@
// Shared local <-> chrome.storage.sync reconciliation for shelf items.
// Loaded by both background.js (via importScripts, Chrome service worker)
// and newtab.html (via <script>, Firefox event page + the shelf UI).
//
// Sync storage only ever holds a lightweight metadata projection of each
// item (no screenshot thumbnails — chrome.storage.sync caps a single key at
// 8KB), one key per item ("m_<id>") so the per-key cap is never a problem;
// the 512-key / ~100KB total quota is the real ceiling. All sync writes are
// best-effort: if the user isn't signed in, is offline, or is over quota,
// errors are swallowed and the extension keeps working from local storage
// alone.
const SYNC_KEY_PREFIX = "m_";
const SYNCED_IDS_KEY = "shelfSyncedIds";
const FAVICON_SYNC_MAX_LEN = 1000;
function syncKeyFor(id) {
return SYNC_KEY_PREFIX + id;
}
// Shared by the reminder highlight and the auto-delete sweep, in both
// background.js and newtab.js, so the two periods can never drift apart.
// Months are approximated as 30 days — no need for calendar precision here.
const UNIT_MS = { days: 86400000, weeks: 604800000, months: 2592000000 };
function periodMs(amount, unit) {
return (amount || 0) * (UNIT_MS[unit] || UNIT_MS.days);
}
// Whether an item is old enough (and eligible) to be auto-deleted.
// Enforces the same "auto-delete period must outlast the reminder period"
// invariant as the settings-panel validation, as a hard safety net: even if
// prefs somehow end up misconfigured (e.g. edited directly in storage), this
// refuses to delete anything until the auto-delete period genuinely exceeds
// the reminder period — so a user is always reminded before something is
// removed, never surprised by it.
function isOverdueForDeletion(item, prefs) {
if (!prefs || !prefs.autoDeleteEnabled || item.archived) return false;
const deleteMs = periodMs(prefs.autoDeleteAmount, prefs.autoDeleteUnit);
const reminderMs = periodMs(prefs.reminderAmount, prefs.reminderUnit);
if (!deleteMs || deleteMs <= reminderMs) return false;
return Date.now() - item.dateAdded >= deleteMs;
}
function metaFromItem(item) {
return {
id: item.id,
url: item.url,
title: item.title,
domain: item.domain,
favicon: item.favicon && item.favicon.length <= FAVICON_SYNC_MAX_LEN ? item.favicon : "",
dateAdded: item.dateAdded,
archived: !!item.archived,
updatedAt: item.updatedAt || item.dateAdded,
surfaceCount: item.surfaceCount || 0,
lastSurfacedAt: item.lastSurfacedAt || 0,
};
}
// "Worth a second look" spotlight: proactively resurfaces a few old,
// forgotten, unarchived items on the new tab page. The more times an item
// has been surfaced without being acted on, the longer before it's shown
// again — so it never nags forever on the same links, but never fully goes
// silent either (it always comes back eventually).
const SPOTLIGHT_BACKOFF_DAYS = [3, 7, 14, 30, 60];
// Used when the reminder feature is off (its default state) — otherwise
// "old enough to matter" would have no threshold at all and the spotlight
// would silently do nothing for most users.
const SPOTLIGHT_FALLBACK_MIN_AGE_MS = periodMs(14, "days");
function spotlightMinAgeMs(prefs) {
return prefs.reminderEnabled
? periodMs(prefs.reminderAmount, prefs.reminderUnit)
: SPOTLIGHT_FALLBACK_MIN_AGE_MS;
}
function passesSpotlightBackoff(item) {
if (!item.lastSurfacedAt) return true;
const tier = Math.min(item.surfaceCount || 0, SPOTLIGHT_BACKOFF_DAYS.length - 1);
return Date.now() - item.lastSurfacedAt >= SPOTLIGHT_BACKOFF_DAYS[tier] * UNIT_MS.days;
}
function isSpotlightCandidate(item, prefs) {
if (item.archived) return false;
if (Date.now() - item.dateAdded < spotlightMinAgeMs(prefs)) return false;
return passesSpotlightBackoff(item);
}
function shuffleInPlace(arr) {
for (let i = arr.length - 1; i > 0; i--) {
const j = Math.floor(Math.random() * (i + 1));
[arr[i], arr[j]] = [arr[j], arr[i]];
}
return arr;
}
// Prefers items that have never been (or were least recently) surfaced,
// with a light shuffle among the leading candidates so it isn't the exact
// same set every single time.
function pickSpotlightItems(items, prefs, count) {
const candidates = items.filter((item) => isSpotlightCandidate(item, prefs));
candidates.sort((a, b) => (a.lastSurfacedAt || 0) - (b.lastSurfacedAt || 0));
const pool = shuffleInPlace(candidates.slice(0, count * 2));
return pool.slice(0, count);
}
async function readSyncMeta() {
const all = await chrome.storage.sync.get(null);
return Object.keys(all)
.filter((k) => k.startsWith(SYNC_KEY_PREFIX))
.map((k) => all[k]);
}
async function readSyncedIds() {
const data = await chrome.storage.local.get(SYNCED_IDS_KEY);
return new Set(data[SYNCED_IDS_KEY] || []);
}
async function writeSyncedIds(idSet) {
await chrome.storage.local.set({ [SYNCED_IDS_KEY]: [...idSet] });
}
// Pulls remote adds/edits into `items` and pushes local adds/edits/deletes
// out to chrome.storage.sync. Returns the merged items array; does not
// persist it — callers are responsible for saving the result locally.
async function reconcileWithSync(items) {
let syncItems, previouslySynced;
try {
[syncItems, previouslySynced] = await Promise.all([readSyncMeta(), readSyncedIds()]);
} catch {
return items;
}
const syncById = new Map(syncItems.map((m) => [m.id, m]));
const localById = new Map(items.map((i) => [i.id, i]));
// Items that were synced before but are no longer in sync storage were
// deleted on another device — drop them here too.
let merged = items.filter((item) => !(previouslySynced.has(item.id) && !syncById.has(item.id)));
// Pull in remote adds/edits.
for (const meta of syncItems) {
const local = localById.get(meta.id);
if (!local) {
// Absent locally but previously synced means *this* device deleted it;
// don't resurrect it from a remote copy that hasn't caught up yet —
// let the deletion below propagate out instead.
if (previouslySynced.has(meta.id)) continue;
merged.push({
id: meta.id,
url: meta.url,
title: meta.title,
domain: meta.domain,
favicon: meta.favicon || "",
image: "",
dateAdded: meta.dateAdded,
archived: meta.archived,
updatedAt: meta.updatedAt,
surfaceCount: meta.surfaceCount || 0,
lastSurfacedAt: meta.lastSurfacedAt || 0,
});
} else if ((meta.updatedAt || 0) > (local.updatedAt || local.dateAdded || 0)) {
local.title = meta.title;
local.domain = meta.domain;
local.archived = meta.archived;
local.dateAdded = meta.dateAdded;
local.favicon = meta.favicon || local.favicon;
local.updatedAt = meta.updatedAt;
local.surfaceCount = meta.surfaceCount || 0;
local.lastSurfacedAt = meta.lastSurfacedAt || 0;
}
}
// Push local adds/edits that are newer than (or missing from) sync.
const nextSyncedIds = new Set(syncById.keys());
const writes = {};
for (const item of merged) {
const remote = syncById.get(item.id);
const localUpdatedAt = item.updatedAt || item.dateAdded;
if (!remote || localUpdatedAt > (remote.updatedAt || 0)) {
writes[syncKeyFor(item.id)] = metaFromItem(item);
nextSyncedIds.add(item.id);
}
}
// Propagate local deletions: ids we used to sync that are gone locally now.
const mergedIds = new Set(merged.map((i) => i.id));
const removals = [];
for (const id of previouslySynced) {
if (!mergedIds.has(id) && syncById.has(id)) {
removals.push(syncKeyFor(id));
nextSyncedIds.delete(id);
}
}
try {
if (Object.keys(writes).length) await chrome.storage.sync.set(writes);
if (removals.length) await chrome.storage.sync.remove(removals);
await writeSyncedIds(nextSyncedIds);
} catch {
// Best effort — local storage remains the source of truth for this device.
}
return merged;
}