Skip to content

Commit 45e64f9

Browse files
ion05claude
andcommitted
Show shortcut suggestions on the Shortcuts page, the popup and the welcome screen.
A card above the shortcut list offers the opt-in, then up to five sites with Add (the prefilled New shortcut form) and dismiss. The popup lists three while its box is empty. The welcome screen offers the same opt-in. Docs: PRIVACY, README, CHANGELOG, AGENTS. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
1 parent 1ed5ed4 commit 45e64f9

14 files changed

Lines changed: 320 additions & 16 deletions

File tree

‎AGENTS.md‎

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,8 @@ src/lib/draft.ts What the edit form edits, and the pure parsing around it
5858
src/lib/text.ts String helpers every surface shares
5959
src/lib/url.ts Small URL helpers
6060
src/lib/amazon-book.ts Amazon product HTML → ISBN → Goodreads URL. Pure.
61+
src/lib/suggest.ts `suggestShortcuts`: visited pages → ranked keyword suggestions. Pure.
62+
src/lib/history.ts The only `chrome.history` caller: optional-permission check, request, read
6163
src/lib/install.ts The onInstalled branch: starter pick, rule sync, welcome tab
6264
src/background.ts MV3 service worker: listener registration, rule sync, omnibox
6365
src/content/ Isolated-world content scripts. `amazon-goodreads.ts` is IIFE-bundled.
@@ -374,6 +376,15 @@ the obvious edit reverses it.
374376
starter pick is written first. It comes apart from "a pick is live" for a format 1 profile
375377
arriving from Settings, or an install whose write failed: those have every shipped shortcut on and
376378
no pick on record, so `initialPicks` opens the starter set ticked rather than an empty screen.
379+
- **`history` stays in `optional_permissions`.** Adding a permission to `permissions` makes Chrome
380+
disable the extension on update for every existing user until they accept the new warning.
381+
`tests/manifest.test.ts` guards it. `src/lib/history.ts` is the only file that requests or reads
382+
it, and it treats "not granted" as no suggestions, never as an error. The request must run inside
383+
the click handler: Chrome refuses it otherwise.
384+
- **`suggest.ts` stays pure, like `resolve.ts`.** No `chrome.*` and no DOM, so the ranking is tested
385+
with a plain array. A suggestion becomes a shortcut only through the ordinary New shortcut form,
386+
so every keyword still passes `validateAlias`. Only `settings.dismissedSuggestions` persists; the
387+
visits never do.
377388

378389
## Verify by executing, not by reading
379390

@@ -388,7 +399,7 @@ stubs `globalThis.chrome` and exercises the **production** path. Note that only
388399

389400
## The test suite
390401

391-
20 files, about 150 cases, under a second. It was 27 files and 1369 before a deliberate cut, and
402+
22 files, about 150 cases, under a second. It was 27 files and 1369 before a deliberate cut, and
392403
the size is a decision rather than an accident. The question a test has to answer is: **if this
393404
vanished and the code broke, would a user notice?**
394405

‎CHANGELOG.md‎

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,14 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
1313
BunnyLol reads the ISBN off the product details (locally) and opens
1414
`goodreads.com/book/isbn/…` for that same book. Pages without an ISBN are
1515
left alone.
16+
- **Suggest shortcuts**, on the Shortcuts page and the welcome screen. With
17+
your permission, BunnyLol reads the last 90 days of your history locally
18+
and offers a keyword for the sites you keep going back to that no shortcut
19+
reaches yet. Add opens the New shortcut form already filled in, × dismisses
20+
a site for good, and the toolbar popup lists up to three while its box is
21+
empty. `history` is an optional permission, asked for only when you click
22+
the button, so updating does not prompt or disable anything. The visits
23+
are never stored or sent; only the dismissed sites are kept, and exported.
1624

1725
## [1.1.0] - 2026-09-02
1826

‎PRIVACY.md‎

Lines changed: 27 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
# Privacy Policy
22

3-
Last updated: 2026-09-07
3+
Last updated: 2026-09-24
44

55
## Summary
66

@@ -12,7 +12,10 @@ telemetry, no remote code and no network requests of its own.
1212
BunnyLol keeps one JSON value under the key `bunnylol.state.v1` (`STORAGE_KEY`
1313
in `src/lib/types.ts`) in `chrome.storage.local` on your device (`saveState`
1414
in `src/lib/storage.ts`). It holds your custom shortcuts, any shipped
15-
shortcuts you turned off or edited, and your settings. Nothing is written to
15+
shortcuts you turned off or edited, and your settings. The settings include
16+
`dismissedSuggestions`, the hostnames of any shortcut suggestions you
17+
dismissed (see below), and nothing else about the sites you visit. Like the
18+
rest of the state, that list is in the exported file. Nothing is written to
1619
`chrome.storage.sync`. Uninstalling the extension deletes it.
1720

1821
The extension also caches its rule-registration status under
@@ -39,8 +42,8 @@ do not match are left untouched and go to the search engine as normal.
3942

4043
## What the extension can see
4144

42-
BunnyLol does not request the `tabs` permission and has no access to your
43-
browsing history. Three places open a tab, and all of them use only
45+
BunnyLol does not request the `tabs` permission. It has no access to your
46+
browsing history unless you opt in, as described below. Three places open a tab, and all of them use only
4447
`chrome.tabs.create` and `chrome.tabs.update`, which do not require that
4548
permission: the toolbar popup (`src/popup/popup.ts`), the omnibox keyword
4649
(`src/background.ts`), and the welcome tab shown once on install
@@ -53,6 +56,26 @@ button navigates your tab to Goodreads. The ISBN never leaves the browser
5356
except as the path of that navigation you started. Pages without an ISBN are
5457
untouched. No other site is injected into.
5558

59+
### Shortcut suggestions (opt-in)
60+
61+
`history` is an optional permission (`optional_permissions` in
62+
`public/manifest.json`). BunnyLol asks for it only when you click **Suggest
63+
shortcuts**, on the Shortcuts page or the welcome screen, and Chrome shows
64+
its own prompt. Until you accept, the extension cannot read your history.
65+
66+
With the permission granted, the options page and the toolbar popup call
67+
`chrome.history.search` for the last 90 days when they open (`loadSuggestions`
68+
in `src/lib/history.ts`). The visits are ranked locally (`suggestShortcuts` in
69+
`src/lib/suggest.ts`) into a few sites you might want a keyword for. Sites a
70+
shortcut already reaches, search engines, `localhost`, IP addresses and hosts
71+
you dismissed are skipped. The visits are never stored and never sent
72+
anywhere; they are read again the next time either page opens. The only
73+
thing kept is the hostname of a suggestion you dismiss with ×.
74+
75+
To revoke the permission, open `chrome://extensions`, click **Details** on
76+
BunnyLol and remove it under **Permissions**, or remove it from Chrome's
77+
extension permission settings. Suggestions stop, and nothing else changes.
78+
5679
## Third parties
5780

5881
None. A shortcut may navigate you to a third-party site such as GitHub or

‎README.md‎

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,14 @@ the same book on Goodreads from its ISBN. No ISBN, no button.
4444

4545
![View on Goodreads button on an Amazon book page](docs/images/amazon-goodreads-button.png)
4646

47+
**Suggest shortcuts** (opt-in) looks at the sites you visit most and offers a keyword for each one
48+
no shortcut reaches yet: a card on the Shortcuts page, and up to three rows in the toolbar popup
49+
while its box is empty. Add opens the New shortcut form already filled in, and × dismisses a site
50+
for good. It needs Chrome's optional `history` permission, requested only when you click the
51+
button. Your history is read locally, on demand, and never stored or sent.
52+
53+
![Suggested shortcuts card on the Shortcuts page](docs/images/suggestions.png)
54+
4755
The toolbar popup gives you autocomplete when you do not want to leave the current page:
4856

4957
<p align="center">

‎docs/images/suggestions.png‎

52.1 KB
Loading

‎src/lib/suggest.ts‎

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -85,7 +85,13 @@ export function suggestShortcuts(
8585
const alias = pickAlias(host, taken);
8686
if (!alias) continue;
8787
taken.add(alias);
88-
out.push({ alias, url: site.origin, name: siteName(site.title, host), host, score: site.score });
88+
out.push({
89+
alias,
90+
url: site.origin,
91+
name: siteName(site.title, host),
92+
host,
93+
score: site.score,
94+
});
8995
}
9096
return out;
9197
}

‎src/options/dom.ts‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,11 +14,12 @@ export function button(label: string, onClick: () => void, className = 'btn'): H
1414
return node;
1515
}
1616

17-
/** The two glyphs the row actions use, as path data for a 16px viewBox. Built
17+
/** The glyphs the row actions use, as path data for a 16px viewBox. Built
1818
* with `createElementNS` rather than markup so nothing here ever parses HTML. */
1919
const ICONS = {
2020
pencil: 'M11.5 2.5a1.4 1.4 0 0 1 2 2L6 12l-3 1 1-3 7.5-7.5zM10 4l2 2',
2121
trash: 'M3 4.5h10M6.5 4.5V3h3v1.5M4.5 4.5l.6 8.5h5.8l.6-8.5M6.8 7v4M9.2 7v4',
22+
close: 'M4.5 4.5l7 7M11.5 4.5l-7 7',
2223
} as const;
2324

2425
export function icon(name: keyof typeof ICONS): SVGElement {

‎src/options/options.css‎

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -690,6 +690,17 @@ a {
690690
}
691691
}
692692

693+
/* The suggestions host is empty until history is read, and stays empty with
694+
nothing to offer; it must not take a gap in the panel meanwhile. */
695+
.suggest:empty {
696+
display: none;
697+
}
698+
699+
/* A suggestion's Add is the whole point of the row, so it is not hover-only. */
700+
.suggest .row-actions .btn {
701+
opacity: 1;
702+
}
703+
693704
.empty {
694705
padding: var(--sp-9) var(--sp-7);
695706
font-size: var(--fs-13);

‎src/options/views/browse.ts‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,7 @@ import { getCommands, getFilter, getState, setFilter, takeNotice } from '../stor
3838
import type { GroupRef, RowRef, RunRef } from './browse-groups';
3939
import { makeGroup, makeRun, move, place, rowsOf, turnOn } from './browse-groups';
4040
import { renderRow } from './browse-row';
41+
import { renderSuggestions } from './suggestions';
4142

4243
/** The group every switched-off shortcut is drawn under, last on the page. */
4344
const HIDDEN_TITLE = 'Hidden shortcuts';
@@ -128,6 +129,7 @@ export function renderBrowse(): Node[] {
128129
const groups = el('div', { class: 'groups' });
129130
const empty = el('div', { class: 'empty' });
130131
empty.hidden = true;
132+
const suggestions = renderSuggestions();
131133

132134
const groupRefs: GroupRef[] = [];
133135
const runRefs: RunRef[] = [];
@@ -306,6 +308,7 @@ export function renderBrowse(): Node[] {
306308
toolbarActions,
307309
],
308310
}),
311+
suggestions,
309312
groups,
310313
empty,
311314
],
@@ -362,6 +365,7 @@ export function renderBrowse(): Node[] {
362365
// this too, so there is one place the filter's effect on the page is
363366
// decided.
364367
toolbarActions.hidden = query !== '';
368+
suggestions.hidden = query !== '';
365369

366370
// `suggest()` gives keyword-first ranking; the substring pass then widens it
367371
// to descriptions so the box behaves like a filter and not just a launcher.

‎src/options/views/suggestions.ts‎

Lines changed: 111 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,111 @@
1+
/**
2+
* The "Suggested shortcuts" card at the top of the Shortcuts route: sites the
3+
* user keeps going back to, each one click from a prefilled New shortcut form.
4+
*
5+
* It sits outside the rows and groups and writes nothing `applyFilter` owns.
6+
* Hiding it while a query is live is `applyFilter`'s call, on the host this
7+
* returns; this file never writes `hidden`. With nothing to offer it leaves the
8+
* host empty and `.suggest:empty` takes it off the page.
9+
*
10+
* A suggestion's name comes off a page title in the user's history, which is
11+
* untrusted text, so it reaches the DOM only through `el` (invariant 11).
12+
*/
13+
14+
import { hasHistoryAccess, loadSuggestions, requestHistoryAccess } from '../../lib/history';
15+
import { prefillFor } from '../../lib/suggest';
16+
import type { Suggestion } from '../../lib/suggest';
17+
import { prettyUrl } from '../../lib/text';
18+
import { el } from '../../ui/dom';
19+
import { button, iconButton, panelCard } from '../dom';
20+
import { go } from '../router';
21+
import { commitSettings, getCommands, getState, reportFailure } from '../store';
22+
23+
const TITLE = 'Suggested shortcuts';
24+
const OFFER =
25+
'Suggest shortcuts for sites you visit often. BunnyLol reads your browsing history on this device only; nothing leaves your browser.';
26+
27+
/** Returned empty and filled once the history has been read, which is async. */
28+
export function renderSuggestions(): HTMLElement {
29+
const host = el('div', { class: 'suggest' });
30+
void fill(host, false);
31+
return host;
32+
}
33+
34+
async function fill(host: HTMLElement, refocus: boolean): Promise<void> {
35+
const granted = await hasHistoryAccess();
36+
const found = granted ? await loadSuggestions(getCommands(), getState().settings) : [];
37+
// The page may have re-rendered while the history was being read, and taken
38+
// this host with it.
39+
if (!host.isConnected) return;
40+
host.textContent = '';
41+
42+
if (!granted) {
43+
const card = panelCard(TITLE, OFFER);
44+
card.body.append(
45+
el('div', {
46+
class: 'btn-row',
47+
children: [
48+
// The request runs straight from the click: Chrome refuses one that
49+
// is not a direct response to a user gesture.
50+
button(
51+
'Suggest shortcuts',
52+
() =>
53+
void requestHistoryAccess().then((ok) => {
54+
if (ok) void fill(host, true);
55+
}),
56+
'btn btn-sm',
57+
),
58+
],
59+
}),
60+
);
61+
host.append(card.section);
62+
return;
63+
}
64+
if (found.length === 0) return;
65+
66+
const card = panelCard(TITLE, 'Sites you keep going back to that no shortcut reaches yet.');
67+
card.body.append(
68+
el('div', { class: 'rows', children: found.map((s) => suggestionRow(s, host)) }),
69+
);
70+
host.append(card.section);
71+
// The button just pressed went out with the old card, which drops focus on
72+
// `<body>`; the first control of the new one is the nearest thing to it.
73+
if (refocus) host.querySelector('button')?.focus();
74+
}
75+
76+
function suggestionRow(s: Suggestion, host: HTMLElement): HTMLElement {
77+
return el('div', {
78+
class: 'row',
79+
children: [
80+
el('div', { class: 'row-keys', children: [el('code', { class: 'chip', text: s.alias })] }),
81+
el('div', {
82+
class: 'row-body',
83+
children: [
84+
el('div', { class: 'row-name', text: s.name }),
85+
el('div', { class: 'row-url', text: prettyUrl(s.url), title: s.url }),
86+
],
87+
}),
88+
el('div', {
89+
class: 'row-actions',
90+
children: [
91+
button(
92+
'Add',
93+
() => go(`#new?prefill=${encodeURIComponent(prefillFor(s))}`),
94+
'btn btn-sm',
95+
),
96+
iconButton(`Dismiss ${s.name}`, 'close', () => {
97+
const settings = getState().settings;
98+
void commitSettings({
99+
...settings,
100+
dismissedSuggestions: [...settings.dismissedSuggestions, s.host],
101+
}).catch(reportFailure);
102+
// Our own write comes back as an echo, and an echo does not
103+
// re-render, so the card refills itself. `commitSettings` applies
104+
// the dismissal before it awaits, so the reload already skips it.
105+
void fill(host, true);
106+
}),
107+
],
108+
}),
109+
],
110+
});
111+
}

0 commit comments

Comments
 (0)