diff --git a/.gitattributes b/.gitattributes index 1125b70..b35829e 100644 --- a/.gitattributes +++ b/.gitattributes @@ -1,3 +1,7 @@ * text=auto eol=lf *.png binary *.woff2 binary + +# Exported artboards, not hand-written source. Marking them keeps GitHub's +# language bar and its diffs about the code somebody actually maintains. +design/canvas/*.dc.html linguist-generated=true diff --git a/.github/ISSUE_TEMPLATE/bug_report.yml b/.github/ISSUE_TEMPLATE/bug_report.yml index 5e90250..9de9d38 100644 --- a/.github/ISSUE_TEMPLATE/bug_report.yml +++ b/.github/ISSUE_TEMPLATE/bug_report.yml @@ -35,10 +35,11 @@ body: attributes: label: Rule status description: >- - The rule-status text at the top of the options page, copied verbatim - (e.g. "Intercepting 148 keywords · 2 exempted by you") + The rule-status text at the top of the options page, copied verbatim, + if any is shown. A healthy profile shows nothing there, which is a + fine answer. validations: - required: true + required: false - type: textarea id: console attributes: diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 0000000..e9940bb --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,18 @@ +# GitHub Actions only, deliberately. +# +# The npm ecosystem is left out because this project's dependency policy is the +# point: four devDependencies, no runtime dependencies, and nothing from +# node_modules reaches the shipped extension. A weekly stream of npm bumps would +# be noise against a lockfile that is meant to move rarely and on purpose. +# +# The action pins are the opposite case. They are `@v4` major tags on somebody +# else's repository, they are the only third-party code that runs with access to +# this repository, and nobody notices when one goes stale. +version: 2 +updates: + - package-ecosystem: github-actions + directory: / + schedule: + interval: weekly + commit-message: + prefix: 'ci:' diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 55ab348..6325476 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -10,7 +10,7 @@ permissions: jobs: check: - name: typecheck + test + build + name: lint + typecheck + test + build runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 @@ -25,6 +25,11 @@ jobs: cache: pnpm - run: pnpm install --frozen-lockfile + + # First, because it is the fastest signal: eslint and prettier --check + # together run in a couple of seconds, well under the typecheck. + - run: pnpm lint + - run: pnpm typecheck - run: pnpm test - run: pnpm build @@ -32,7 +37,7 @@ jobs: # `pnpm build` runs scripts/gen-icons.mjs, so a change to the generator or # to --accent repaints these. Committed PNGs that the generator no longer # produces are a silent drift no other step can see. - - run: git diff --exit-code -- public/icons store + - run: git diff --exit-code -- public/icons # The packer has no test, it writes a binary nothing else reads, so the # only cheap guard is that it still runs over a real build. `release/` is diff --git a/.prettierignore b/.prettierignore new file mode 100644 index 0000000..84cf0b7 --- /dev/null +++ b/.prettierignore @@ -0,0 +1,27 @@ +# Prettier already skips file types it has no parser for, so this lists only +# what it WOULD format and should not. + +# Build output and packaging artefacts. Nothing here is edited by hand. +dist/ +release/ + +# Machine-written, and pnpm owns the formatting of its own lockfile. +pnpm-lock.yaml + +# The approved design bundle. AGENTS.md: change it through a design review, not +# in passing. `design/canvas/*.dc.html` are exported artboards (.gitattributes +# already marks them linguist-generated), and `design/tokens.css` is parsed as +# text by scripts/gen-icons.mjs, which throws if the accent declarations move. +# Its trailing contrast-ratio comments are aligned by hand and carry the audit. +design/ + +# The dispatch page's inline stylesheet is deliberately minified: this page's +# whole job is to redirect before it paints, so it fetches no font and loads no +# sheet, and the values are copied by hand from design/tokens.css rather than +# substituted at build time. +go.html + +# Prose is hand-wrapped at about 100 columns and uses *emphasis*. Prettier +# rewrites that to _emphasis_ and reflows paragraphs, which is churn on text no +# formatter can improve. The wrap width is a review convention, not a build rule. +*.md diff --git a/.prettierrc.json b/.prettierrc.json new file mode 100644 index 0000000..5e849a4 --- /dev/null +++ b/.prettierrc.json @@ -0,0 +1,10 @@ +{ + "printWidth": 100, + "tabWidth": 2, + "useTabs": false, + "semi": true, + "singleQuote": true, + "trailingComma": "all", + "arrowParens": "always", + "endOfLine": "lf" +} diff --git a/AGENTS.md b/AGENTS.md index 6263f58..830c883 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -8,8 +8,10 @@ Context for AI coding agents working in this repo. Read this before changing any A Chrome Manifest V3 extension that turns the address bar into a command line, in the style of the bunnylol command bar used inside Meta. Type `gh facebook/react` and land on the repo, not on a -search results page. It is not affiliated with Meta, and the README and any listing copy have to -say so. +search results page. It is not affiliated with Meta, and the README, any listing copy AND the extension's own UI have +to say so. The welcome screen is the one place a user or a store reviewer meets the claim at +runtime, so the disclaimer lives next to it in `src/options/views/welcome.ts` rather than only in +the documentation. The shipped shortcuts are plain data in `src/lib/commands.ts`, grouped into packs the user picks from on first run. Everything a user then does to one (rename, re-key, move, switch off, delete) is @@ -44,8 +46,14 @@ src/lib/validate.ts The single validation boundary: aliases, URLs, section i src/lib/overrides.ts Shortcut identity (`shortcutId`, `u:` ids) + the edit/delete/section algebra src/lib/onboarding.ts What a pack pick means: `applyCategoryPick`, `migrateNewBuiltins` src/lib/merge-import.ts Folding an import onto the state already here (`mergeOverrides`) -src/lib/storage.ts chrome.storage.local persistence, JSON import/export, the v1 readers -src/lib/dnr.ts declarativeNetRequest rule generation + syncRules +src/lib/storage.ts chrome.storage.local persistence, export, and the entry point below +src/lib/storage/normalize.ts LENIENT reader: any blob in, a usable state out. Never throws. +src/lib/storage/parse-import.ts STRICT import parser + the v1 file reader. Refuses by name. +src/lib/storage/shared.ts What both need: guards, the shipped ids, custom-id assignment. +src/lib/dnr.ts `syncRules`: the serialized rebuild + the remembered RuleStatus +src/lib/dnr/rules.ts Every registrable rule. `buildRules` and `syncRules` share it. +src/lib/dnr/keywords.ts Which aliases survive the caps, and the two orders they live in +src/lib/dnr/fit.ts Chrome's RE2 check, resplitting a refused shard, coverage wording src/lib/draft.ts What the edit form edits, and the pure parsing around it src/lib/text.ts String helpers every surface shares src/lib/url.ts Small URL helpers @@ -57,8 +65,7 @@ src/options/ Shortcut manager UI (below) src/popup/ Toolbar command bar design/ The approved design system. `tokens.css` is shipped; the rest is review. scripts/ gen-icons.mjs, package.mjs, and `scripts/lib/` (pure, importable helpers) -store/ Web Store listing assets. Outside `public/`, so never packed into dist/. -docs/ fonts.md (the bundled Inter), chrome-web-store.md (the submission crib) +docs/ fonts.md (the bundled Inter), images/ (the README screenshots) extras/packs/ Importable JSON packs. Data, not code; not compiled. ``` @@ -75,9 +82,22 @@ src/options/dom.ts Stateless widgets the views assemble panels from src/options/rule-status.ts The pill in the topbar and the coverage line in Settings src/options/status.ts Pure: a `RuleStatus` in, the words and the tone out src/options/model/*.ts browse, collapse, form, welcome: the decisions, without a DOM -src/options/views/*.ts browse, form, settings, data, welcome, packs: the DOM +src/options/views/*.ts form, settings, data, welcome, packs: the DOM +src/options/views/browse.ts The Shortcuts route: panel assembly, and `applyFilter` +src/options/views/browse-groups.ts The group headings, the runs, and refiling a row between + them. Writes nothing that is on screen. +src/options/views/browse-row.ts One row: the chips, the destination, Edit/Delete/switch ``` +The browse route is three files split along one line. `applyFilter` in `views/browse.ts` is the only +writer of `row.hidden`, `rowsHost.hidden`, every count and the "omnibox only" badge, so the other two +build and refile and write none of them: the headings and the bulk-action buttons are built EMPTY, +and every function that changes what a group holds takes the repaint as a callback instead of doing +it. That makes the rule a grep over two short modules rather than a reading of one 380-line closure, +which is where two shipped bugs lived. `browse-groups.ts` is plain functions over their arguments, +not a factory closed over the page state: a factory would have to be built before `applyFilter` and +then be read by it, putting a mutable slot on the very seam the rule lives on. + `views/welcome.ts` and `views/packs.ts` are two screens over one question. `#welcome` is the tab the install opens, so it introduces the product and offers Skip; `#packs` is reached on purpose from Settings, so it says what saving does and offers Save and Cancel. The cards, the ticks and the one @@ -111,8 +131,14 @@ meta, zoom, meet, tracking, track, instagram, whatsapp, word. ## Invariants that were violated during development -Every one of these was a real shipped bug caught by adversarial verification. They have regression -tests. **If a test in this list fails, do not "fix" the test.** +Every one of these was a real shipped bug caught by adversarial verification. **If a test in this +list fails, do not "fix" the test.** + +The suite was cut back to the behaviour a user meets (see "The test suite" below), so an invariant +here carries **one** test: the smallest one that goes red when that bug comes back. Where an +invariant is named without a test file, nothing covers it any more and the note is the only thing +standing between it and the next reviewer. Two are in that state, 11 and 14, and both were already +uncovered before the cut. 1. **BunnyLol must never intercept its own output.** Some commands resolve to a URL on a search engine we intercept (`g`, `ddg`, and historically `weather`). `destination()` in `resolve.ts` @@ -125,31 +151,40 @@ tests. **If a test in this list fails, do not "fix" the test.** 2. **DNR rule priority is `redirect (1) < escape (2) < allow (3)`,** and `fitPlan` fails closed: an engine gets redirect rules only if Chrome accepted both its allow and escape rules. Registering redirects without them leaves the user in a redirect loop with no escape. + Guarded by `tests/dnr.test.ts` `describe('force-search escape rules')` for the ladder, and by + `tests/sync-rules.test.ts` `describe('a Chrome that refuses the passthrough allow rule')` for the + fail-closed half, on the production path. 3. **A failed sync must not leave stale rules live.** `updateDynamicRules` is atomic, so a throw leaves the *previous* rules running. `syncRules` retries remove-only, and if that also fails it - reports the coverage genuinely still live rather than claiming zero. + reports the coverage genuinely still live rather than claiming zero. Guarded by + `tests/sync-rules.test.ts` `describe('a Chrome that rejects the rule update')`. 4. **The DNR regex must consume the whole URL remainder,** not just the terminator. Chrome appends `&sourceid=chrome&ie=UTF-8` (Bing: `&PC=U316&FORM=CHROMN`, DDG: `&t=hc`) to address-bar searches. RE2 has no lookahead, so the pattern swallows the tail and the substitution drops it. + Guarded by `tests/dnr.test.ts` "drops the parameters Chrome appends". 5. **Keyword retention is ranked separately from alternation ordering.** The alternation must be longest-first so `github` beats `gh`. But truncating *that* order removes exactly the short hot aliases: at ~400 custom shortcuts, `gh`, `g` and `npm` silently stopped being intercepted. + Guarded by `tests/sync-rules.test.ts` "keeps every builtin alias and drops only custom ones". -6. **All alias, URL and section validation goes through `src/lib/validate.ts`.** Nothing re-derives - a rule locally. Today's callers are the import parser (`storage.ts`), the override algebra +6. **All alias, URL and section validation goes through `src/lib/validate.ts`** (guarded by + `tests/validate.test.ts`). Nothing re-derives a rule locally. Today's callers are both storage + readers (`storage/parse-import.ts` strictly, + `storage/normalize.ts` and `storage/shared.ts` leniently), the override algebra (`overrides.ts`), the one shortcut form (through `draft.ts` and `model/form.ts`), the section - editor in Settings, and `resolve.ts` for `isInterceptableAlias`. That list will grow, so add a - call site rather than a local rule. When the rule lived in whichever module needed it, each had - a different hole: whitespace aliases and scheme-less URLs both persisted happily while being - unusable. `validateAlias` also rejects an alias starting with an escape prefix, since `resolve()` - strips that before the key map is ever consulted. + editor and the "Exempt keywords" field in Settings, and `resolve.ts` for `isInterceptableAlias`. + That list will grow, so add a call site rather than a local rule. When the rule lived in + whichever module needed it, each had a different hole: whitespace aliases and scheme-less URLs + both persisted happily while being unusable. `validateAlias` also rejects an alias starting with + an escape prefix, since `resolve()` strips that before the key map is ever consulted. 7. **Free text never goes into a slot expecting a specific shape.** Tracking numbers, Zoom meeting ids, phone numbers and dictionary headwords all guard their input and degrade to a search. - Otherwise `fedex near me open now` renders "tracking number not found". + Otherwise `fedex near me open now` renders "tracking number not found". Guarded by + `tests/handlers.test.ts` `describe('shape-guarded slots')`. 8. **Arguments are never silently dropped**, with one deliberate, enumerated exception. The cloud consoles (`aws`, `gcp`, `vercel`, `netlify`, `cf`) had their `site:` doc search removed on @@ -157,25 +192,32 @@ tests. **If a test in this list fails, do not "fix" the test.** third is a decision someone makes, not a test that quietly stopped caring. 9. **No command may have a write side effect as its default argument behaviour.** `td bank near me` - used to open Todoist's quick-add *prefilled*. Quick-add lives on a separate `tda` alias. + used to open Todoist's quick-add *prefilled*. Quick-add lives on a separate `tda` alias. Guarded + by `tests/commands.test.ts` "never turns a misread search into a write". 10. **`buildKeyMap` is first-writer-wins** and `mergeCommands` puts custom commands first, so a - user's own `gh` shadows the builtin rather than being ignored. + user's own `gh` shadows the builtin rather than being ignored. Guarded by + `tests/resolve.test.ts` "lets a custom command shadow a builtin alias". 11. **User text reaches the DOM only via `textContent`/`createElement`.** A shortcut name is untrusted input. `background.ts` XML-escapes omnibox descriptions or Chrome silently drops the - suggestion. + suggestion. **No test covers this**, and none ever did: it is a convention held by review and by + the ban on `innerHTML`. Grep before you add a surface. 12. **`resolve()` never throws.** A handler that blows up degrades to the command's bare - destination. + destination. Guarded by `tests/resolve.test.ts` "never throws and always yields a url, however + hostile the query". 13. **`RuleStatus` separates a fatal `error` from a partial-coverage `warning`.** They used to be one field, which made the options page render the red "Rules not registered" state for a single - dropped keyword and left the amber state unreachable. + dropped keyword and left the amber state unreachable. Guarded by the one case left in + `tests/status-pill.test.ts`. 14. **Vite's `crossorigin` and modulepreload tags are stripped** in `vite.config.ts`. On a `chrome-extension://` page the browser treats `crossorigin` as a cross-world mismatch and - discards the preload, so the attribute costs the very thing it was meant to enable. + discards the preload, so the attribute costs the very thing it was meant to enable. **No test + covers this**, and none ever did. It is visible only in a built `dist/` page: grep the output + for `crossorigin` if you touch the plugin. 15. **`syncRules` is serialized, with one trailing coalesced slot.** Rule ids are renumbered densely from the current keyword count, so two overlapping rebuilds read the same `existing` @@ -197,13 +239,13 @@ tests. **If a test in this list fails, do not "fix" the test.** `builtin` or `id`. That is the difference between renaming GitHub and pointing the `github` handler at your own host. An edit whose `url` is blank or unparseable inherits the shipped one, because `rawDestination` returns `cmd.url` and an empty string is not a destination (invariant - 12). Guarded by `tests/overrides.test.ts`, by `tests/overrides-security.test.ts` (which drives - the hostile shapes one field at a time) and by the whole-path test in `tests/storage.test.ts` - `describe('an edit cannot smuggle behaviour through the import')`, which drives the JSON - through `importJson` → `applyImport` → `mergeCommands` rather than calling `applyEdit` - directly. Its last case hands `mergeCommands` an override object the parser never saw: the - storage boundary strips these fields too, so without it the whole block stays green even if - `applyEdit` went back to spreading. + 12). Two tests, because there are two code paths and each answers a different way: + `tests/overrides.test.ts` "ignores handler, provider, builtin and id" for `applyEdit`, and + `tests/storage.test.ts` "holds even when the edit reaches the merge unparsed" for the storage + boundary. The second hands `mergeCommands` an override object the parser never saw, so it stays + red even if only `applyEdit` is fixed. The file that drove the hostile shapes one field at a + time, `tests/overrides-security.test.ts`, was deleted in the cut: it re-covered these two + through a wrapper. 17. **A category is an open section id, and every lookup keyed by one is hostile input.** `validateSectionId` is deliberately permissive. It accepts a builtin id, because that is how a @@ -219,16 +261,18 @@ tests. **If a test in this list fails, do not "fix" the test.** hand-edit JSON the user did not write. The one category refusal left is structural: a `category` that is not a string names no id to degrade to. A pack SHOULD still declare the sections it files things under (`extras/packs/removed-commands.json` is the worked example); it - just is not made to. Guarded by `tests/overrides.test.ts`, `tests/storage.test.ts` and - `tests/overrides-security.test.ts`. + just is not made to. Guarded by `tests/overrides.test.ts` "does not answer with something off + Object.prototype" for the lookup, and by `tests/storage.test.ts` for the two degrade paths ("an + edit whose category names no section loses the category, not the command" and "files a shortcut + under \"custom\" when the STORED blob lost the section"). ## Smaller rules, easy to undo by accident These are not invariants, since no bug shipped from them. But each is a decision with a reason, and the obvious edit reverses it. -- **`applyFilter` in `views/browse.ts` is the only writer of `row.hidden`, `rowsHost.hidden` and - every count on the page.** Collapse hides a group by writing the rows host. The filter hides +- **`applyFilter` in `views/browse.ts` is the only writer of `row.hidden`, `rowsHost.hidden`, every + count on the page and the "omnibox only" badge.** Collapse hides a group by writing the rows host. The filter hides individual rows and force-shows a collapsed group that matches. Two writers means a row that a cleared filter never brings back. The on/off switch is the one control that changes what is on screen without a re-render, and it still does not write any of those: it moves the row's node @@ -299,8 +343,8 @@ the obvious edit reverses it. `confirmOpen` until the user answers: an Open button that takes focus, so Enter proceeds, and the escape search, whose own navigation is the outcome (the promise deliberately never resolves down that path, because a second navigation would race it). A confirmation the page navigates away from - on its own is a delay, not a confirmation. `tests/go-dispatch.test.ts` reads the source and fails - if a timer or the old toast node comes back. + on its own is a delay, not a confirmation. Nothing tests this now: the suite that did read + `go.ts` as source text rather than running it, which is the shape the test cut removed. - **`Settings.defaultAi` is gone; `settings.aiTemplates` survives with no UI.** The `?` command that read the default was deleted outright rather than parked in the removed-commands pack, because a keyword whose whole job was to read a setting that no longer exists has nothing to come back to. A @@ -309,6 +353,25 @@ the obvious edit reverses it. by the import parser; it is edited through an exported JSON file. Do not delete the plumbing because no card writes it, and do not reintroduce a settings field the resolver would have to read to answer a keyword. +- **Meta shortcuts ship a RELATIVE url.** `bl`, `add` and `set` point at `options.html#…` and the + dispatch page absolutises it. Applying `withScheme` unconditionally on save turned a no-change + Save into a stored `https://options.html#help` that opened nothing, permanently. See `keptUrl` in + `src/lib/draft.ts`. +- **The live preview substitutes a shipped command at its own registry index.** `buildKeyMap` is + first-writer-wins, so appending the draft instead would preview a resolution the save does not + produce. See `previewCommands` in `src/options/model/form.ts`. +- **A re-minted custom id has to be rewritten in `disabled` and `deleted` too.** Otherwise those + entries follow the wrong shortcut and a newly imported command inherits the incumbent's history. + See `landedAs` in `src/lib/merge-import.ts`. +- **`--accent` and `--accent-fg` must stay flat hexes.** `scripts/gen-icons.mjs` parses those exact + declarations to colour the icon, so wrapping either in `light-dark()` throws the build. The same + reason pins `minimum_chrome_version` to 123: `light-dark()` needs it. +- **`.spec-row` is a harness class.** It belongs to `design/preview.css` and to the artboards. The + product renders `.row`. A harness class must never reach the shipped sheet. +- **`hasOnboarded` is true on every real install** by the time the welcome tab opens, because the + starter pick is written first. It comes apart from "a pick is live" for a format 1 profile + arriving from Settings, or an install whose write failed: those have every shipped shortcut on and + no pick on record, so `initialPicks` opens the starter set ticked rather than an empty screen. ## Verify by executing, not by reading @@ -316,14 +379,48 @@ The most valuable bugs here were found by *running* code, not inspecting it. The correct to three reviewers. Applying it to a real Chrome-generated URL exposed it immediately. When you change routing, build the real rules and replay real URLs through them. -`tests/helpers/rules.ts` has the matcher. `tests/sync-rules.test.ts` stubs `globalThis.chrome` and -exercises the **production** path. Note that only tests call `buildRules`, so a test that drives -`buildRules` alone is not testing what ships. +`buildRules` and the production path share `src/lib/dnr/rules.ts`, so what a `buildRules` test +omits is precisely `dnr/fit.ts`. `tests/helpers/rules.ts` has the matcher. `tests/sync-rules.test.ts` +stubs `globalThis.chrome` and exercises the **production** path. Note that only tests call +`buildRules`, so a test that drives `buildRules` alone is not testing what ships. + +## The test suite + +20 files, about 150 cases, under a second. It was 27 files and 1369 before a deliberate cut, and +the size is a decision rather than an accident. The question a test has to answer is: **if this +vanished and the code broke, would a user notice?** + +What is here: `resolve()` turning a typed query into a destination and honouring the escape prefix; +one or two shapes per smart handler; the redirect rules matching a real Chrome-generated search URL +without swallowing their own output; import/export round-tripping and an import refusing a file that +would corrupt the profile; the override layer (edit, disable, delete, restore, sections); and a +handful of property tests over the shipped registry, which is why adding a command needs no new +test. + +What is deliberately not here, and should not come back: + +- **Design and token tests.** The stylesheets are reviewed, not asserted on. +- **View tests that assemble a DOM.** The decisions behind a view are pure and live in + `src/options/model/*.ts`; those are testable and a few are tested. The DOM they produce is + verified in a browser. +- **Tests that a removed feature stayed removed.** They test history, not behaviour. +- **Tests that read source text** rather than running it. +- **Exhaustive sweeps.** Where a table ran one assertion over all 96 registry rows, it is one + property test that names every row that drifted. `it.each` over a registry is how a suite reaches + 1369 cases without covering anything new. +- **Duplicates**, including a test that covers through a wrapper what another covers through the + thing being wrapped. + +An invariant above keeps ONE test, and the comment saying which bug it guards stays with it: that +comment is why the test is worth its line. Adding a test is welcome when it answers the question at +the top of this section. Adding one per branch is not. ## Commands ```bash pnpm install +pnpm lint # eslint + prettier --check +pnpm format # prettier --write pnpm test # vitest pnpm typecheck # tsc --noEmit pnpm build # gen-icons + typecheck + vite build -> dist/ @@ -343,17 +440,29 @@ gitignored. - pnpm, pinned via `packageManager`. Do not run `npm install`: it creates a second lockfile. - TypeScript strict, `verbatimModuleSyntax`: use `import type` for type-only imports. - Import siblings without a file extension. -- 2-space indent, single quotes, semicolons, no default exports. -- **No new dependencies.** The whole thing runs on four devDependencies; inline the functionality. +- 2-space indent, single quotes, semicolons, no default exports. Enforced: `eslint.config.js` and + `.prettierrc.json`, run together by `pnpm lint`. Prettier is set to the style already here + (printWidth 100, derived from where the code actually wraps), so it is not a reformat waiting to + happen. Every eslint rule switched off names the convention it was fighting; read that before + turning one back on. `design/`, `go.html` and Markdown are outside the formatter, for reasons + `.prettierignore` gives. +- **No new dependencies in what ships.** Nothing is bundled into the extension but this repo's own + source and one font. Dev tooling is judged on its own merits and is currently prettier and eslint + on top of typescript, vite and vitest. Adding to that list is a decision somebody makes on + purpose; adding a runtime dependency is not on the table. - Comment only where the *reason* is non-obvious. Do not restate the code. - Vanilla TS and CSS in the UI. No framework. - Colours, sizes and spacing in the UI sheets come from `design/tokens.css`. No literal hex, no raw - `font-size: Npx`, and never `color: var(--accent)`. `tests/tokens.test.ts` enforces it. `--accent` - is a fill (2.04:1 on white). `--accent-text` is the readable half-lightness twin for text, links - and the focus ring. + `font-size: Npx`, and never `color: var(--accent)`. This used to be enforced by + `tests/tokens.test.ts`, 72 cases over the stylesheets; it is a review rule now. `--accent` is a + fill (2.04:1 on white). `--accent-text` is the readable half-lightness twin for text, links and + the focus ring. - `src/lib` and `src/options/model` must import cleanly under vitest's `environment: node`: no `document`, no `chrome.*` at module scope. That is what makes the pure decisions testable without - a DOM, and a stray import breaks a suite rather than a feature. + a DOM, and a stray import breaks a suite rather than a feature. Every suite runs under `node` + now, and there is no DOM environment at all: the one jsdom suite went in the test cut and jsdom + went with it. A view is verified in a real browser, which is where layout, `focus()` inside a + `display: none` subtree and the CSS `order` reordering are visible anyway. - Do not edit `extras/` expecting it to compile. It is intentionally outside tsconfig. - `design/` is the approved design bundle. Change it through a design review, not in passing. @@ -387,9 +496,9 @@ Commands are plain data in `src/lib/commands.ts`. When adding or removing one: ## Review workflow Project convention: substantial work arrives as **distinct commits sliced by architectural layer**, -so each one carries a single reviewable idea and passes the gate (`pnpm typecheck && pnpm test && -pnpm build`) on its own. Verify that standing alone: a test that imports a module from a later -commit silently breaks the property without failing anything. +so each one carries a single reviewable idea and passes the gate (`pnpm lint && pnpm typecheck && +pnpm test && pnpm build`) on its own. Verify that standing alone: a test that imports a module from +a later commit silently breaks the property without failing anything. Those commits may be stacked as branches, each PR based on the previous one, or landed as one branch. If you stack them, **do not pass `--delete-branch`** when merging: deleting a parent branch diff --git a/CHANGELOG.md b/CHANGELOG.md index a838297..a5ef4b0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,8 +11,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added -- `gr` (also `goodreads`): search books and reviews on Goodreads. It shipped - until v1.1.0 dropped the `media` category it was filed under; it is back, in +- `gr` (also `goodreads`): search books and reviews on Goodreads, filed under Search. - `track ` (also `pkg`): one keyword for any parcel. BunnyLol reads the carrier (UPS, USPS, FedEx or DHL) off the shape of the number and opens @@ -83,20 +82,20 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 it is about to open, and offers an Open button, which holds the focus, and the escape search. The 1.2 second toast it replaces navigated on its own, which is a delay rather than a confirmation. -- The rule-status pill says **Shortcuts active** instead of counting - keywords, and **Some keywords not intercepted** when coverage is partial. - The count moved every time a shortcut was switched on or off, and nobody - acted on it. The numbers that do matter, what you exempted and what Chrome - refused, are still on the line under it and on the Settings coverage line. - `web_accessible_resources` is narrowed to `go.html`. `go.js` and `assets/*` are same-origin subresources of an extension page and never needed an entry. Listing them exposed them, and the shipped sourcemaps, to the search engines. ### Removed -- The green *Shortcuts active* pill. The rule-status pill in the topbar now - appears only when there is something to act on: partial coverage, a failed - sync, or interception switched off. A healthy profile shows nothing. +- The always-on rule-status pill. It now appears only when there is something + to act on: partial coverage, a failed sync, or interception switched off. A + healthy profile shows nothing, and neither does one whose only shortfall is a + keyword you exempted yourself. When it does appear it says **Some keywords + not intercepted** rather than counting keywords, since the count moved every + time a shortcut was switched on or off and nobody acted on it. The numbers + that do matter, what you exempted and what Chrome refused, are on the line + under it and on the Settings coverage line. - The `?` shortcut and the **Default AI** setting it read (`settings.defaultAi`). Pick the assistant with its own keyword instead: `c`, `gpt`, `gem` or `cc`. - The **AI prompt templates** card. `settings.aiTemplates` still overrides a @@ -117,6 +116,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [1.0.0] - 2026-09-01 +Never published anywhere. Recorded as the baseline the 1.1.0 entries are +written against, which is why it has no link below. + ### Added - First release: keyword shortcuts for the address bar via @@ -124,5 +126,4 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 popup, and an omnibox keyword (`bl`). [Unreleased]: https://github.com/ion05/bunnylol/compare/v1.1.0...HEAD -[1.1.0]: https://github.com/ion05/bunnylol/compare/v1.0.0...v1.1.0 -[1.0.0]: https://github.com/ion05/bunnylol/releases/tag/v1.0.0 +[1.1.0]: https://github.com/ion05/bunnylol/releases/tag/v1.1.0 diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 9a3e762..9e97a9e 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -3,11 +3,14 @@ Bug reports, new shortcuts and fixes are all welcome. Before you start: - **[AGENTS.md](AGENTS.md) is the architecture note**, and its "Invariants that were violated during - development" section is not decoration. Every entry is a bug that already shipped once. Every one - has a regression test. And every one looks like reasonable code, which is why they came back. Read - it before you touch routing, validation or the override layer. -- **No new dependencies**, devDependencies included. The whole project runs on a handful of build - tools. If you need a helper, inline it. + development" section is not decoration. Every entry is a bug that already shipped once, and every + one looks like reasonable code, which is why they came back. Most carry one regression test, named + in the entry; two carry none and say so. Read it before you touch routing, validation or the + override layer. +- **No new dependencies in what ships.** Nothing is bundled into the extension but this repo's own + source and one font. If you need a helper, inline it. Dev tooling is judged on its own merits and + is currently prettier and eslint on top of typescript, vite and vitest. Adding to that list is a + decision somebody makes on purpose. ## Setup @@ -26,11 +29,12 @@ extension card after every build. ## The gate ```bash -pnpm typecheck && pnpm test && pnpm build +pnpm lint && pnpm typecheck && pnpm test && pnpm build ``` -All three, green, on **every** commit, not just at the end of a branch. CI runs exactly this on pull -requests, plus `git diff --exit-code -- public/icons store`. `pnpm build` regenerates the icons from +All four, green, on **every** commit, not just at the end of a branch. CI runs exactly this on pull +requests, plus `git diff --exit-code -- public/icons store`. `pnpm lint` goes first because it is +the fastest of the four and the cheapest to fix. `pnpm build` regenerates the icons from `design/tokens.css`. So if you change the generator or the accent colour and do not commit the result, it shows up as a dirty tree. @@ -46,6 +50,23 @@ the matcher. Then load the extension and try it. When you add a test, make sure it fails when the thing it guards is broken. Break the code, watch it go red, put it back. +## The test suite + +20 files, about 150 cases, under a second. It is small on purpose. Before you add a test, answer +this: **if it vanished and the code broke, would a user notice?** + +It covers `resolve()` turning a typed query into a destination and honouring the `\` and `=` escape, +one or two shapes per smart handler, the redirect rules against real Chrome-generated search URLs, +import and export, the override layer, and a few property tests over the shipped registry (which is +why adding a command usually needs no new test). + +It deliberately does not cover stylesheets or design tokens, the DOM a view assembles (the decisions +behind it are pure, in `src/options/model/*.ts`, and those are testable), that a removed feature +stayed removed, or the same rule twice through a wrapper. A table that runs one assertion over every +row of the registry is one property test, not 96 cases: that is how a suite gets to four figures +without covering anything new. Views are verified in a real browser, which is the only place layout +and focus behaviour are visible anyway. + ## Adding or changing a command Commands are plain data in `src/lib/commands.ts`. @@ -75,15 +96,24 @@ A shortcut only *you* need does not need a PR at all. Make it in the options pag - Vanilla TS and CSS in the UI. No framework. - Colours, sizes and spacing in the UI stylesheets come from `design/tokens.css`. No literal hex, no raw `font-size: Npx`, and never `color: var(--accent)`, because the sand accent is a fill and - fails contrast as text. `tests/tokens.test.ts` enforces all of it. + fails contrast as text. Reviewed by hand: the 72-case suite that enforced it went with the rest + of the design tests. - **Comment only where the reason is non-obvious.** Do not restate the code. A comment that says *why this and not the obvious alternative* is worth more than five that narrate what the next line does. - User text reaches the DOM only through `textContent` and `createElement`. A shortcut name is untrusted input. -There is no linter or formatter, and that is deliberate: one fewer dependency, and one fewer config -to argue with. Match the surrounding code. +`import type`, the indent, the quotes, the semicolons and the ban on default exports are all +enforced now. `pnpm lint` runs eslint and `prettier --check`; `pnpm format` rewrites the files. +Prettier is set to the style already here rather than the other way round, so running it over a +clean tree changes nothing. + +Both configs are short and commented. Every rule eslint has switched off names the convention it was +fighting, so if a rule is in your way, read why it is off before turning it back on. Four things are +outside the formatter on purpose: `design/` is the approved design bundle and changes through a +design review, `go.html` carries a deliberately minified inline stylesheet the dispatch page needs +to paint without one, Markdown is hand-wrapped prose, and `pnpm-lock.yaml` belongs to pnpm. ## Pull requests @@ -105,3 +135,25 @@ auto-closes the PR that targets it. Do not open a public issue for a vulnerability. [SECURITY.md](SECURITY.md) has the private reporting route. + +## Maintenance and releases + +This project is maintained by one person, [@ion05](https://github.com/ion05). Issues and pull +requests are read, but a reply may take a week. That is the honest expectation rather than a +promise of anything faster. + +Versions follow [semantic versioning](https://semver.org), and the stored state format is the +compatibility surface. A new field that older builds ignore is a minor. A change that makes an +older export unreadable is a major. Adding or removing a shipped shortcut is a minor, since a +profile that never touched it still resolves. + +A release is: + +1. Bump `version` in `package.json` and `public/manifest.json` in the same commit. They are checked + against each other by `tests/manifest.test.ts`, so they cannot drift. +2. Add the section to [CHANGELOG.md](CHANGELOG.md) and the link at the foot of that file. +3. Run the gate, then `pnpm package`, which rebuilds and writes `release/bunnylol-.zip`. + Build fresh rather than trusting a zip already sitting in `release/`: the Web Store enforces + monotonic versions, so uploading a stale build under a new version costs you the next one too. +4. Tag `vX.Y.Z`, push the tag, and attach that zip to a GitHub release. +5. Upload the same zip to the Web Store. diff --git a/PRIVACY.md b/PRIVACY.md index 22e1922..6afea0f 100644 --- a/PRIVACY.md +++ b/PRIVACY.md @@ -1,6 +1,6 @@ # Privacy Policy -Last updated: 2026-09-01 +Last updated: 2026-09-03 ## Summary @@ -20,6 +20,12 @@ The extension also caches its rule-registration status under until the browser closes and never reaches disk. It holds counts and, when Chrome rejects a pattern, the affected keywords. +The options page keeps one more value, `bunnylol.collapsed`, in the ordinary +`localStorage` of its own extension page (`COLLAPSE_KEY` in +`src/options/model/collapse.ts`). It is the list of shortcut groups you have +folded on that page, and nothing else. It is per-machine view state rather +than settings, which is why it is not in the exported file. + ## What happens when you type in the address bar BunnyLol registers local `declarativeNetRequest` redirect rules for @@ -34,9 +40,12 @@ do not match are left untouched and go to the search engine as normal. ## What the extension cannot see BunnyLol has no content scripts, reads no page content, and has no access to -your browsing history. It does not request the `tabs` permission. The popup -uses only `chrome.tabs.create` and `chrome.tabs.update` -(`src/popup/popup.ts`), which do not require it. +your browsing history. It does not request the `tabs` permission. Three places +open a tab, and all of them use only `chrome.tabs.create` and +`chrome.tabs.update`, which do not require that permission: the toolbar popup +(`src/popup/popup.ts`), the omnibox keyword (`src/background.ts`), and the +welcome tab shown once on install (`src/lib/install.ts`). Neither call can +read a tab, only point one at a URL. ## Third parties diff --git a/README.md b/README.md index bf507f3..de54f21 100644 --- a/README.md +++ b/README.md @@ -1,7 +1,14 @@ +

+ BunnyLol logo +

+ # BunnyLol -BunnyLol turns the Chrome address bar into a command line. Type a keyword and its arguments, and you -land on the page itself, not on a results page: +[![CI](https://github.com/ion05/bunnylol/actions/workflows/ci.yml/badge.svg)](https://github.com/ion05/bunnylol/actions/workflows/ci.yml) +[![License: MIT](https://img.shields.io/badge/license-MIT-blue.svg)](LICENSE) + +BunnyLol enables you to set custom shortcuts for websites in your browser, including faster +searches for supported websites. ``` gh facebook/react → github.com/facebook/react @@ -9,15 +16,44 @@ npm zod → npmjs.com/package/zod c explain monads → Claude, with the prompt already in the box ``` -This is an independent, unofficial project. It is inspired by the bunnylol-style command bar used -inside Meta. **Not affiliated with, endorsed by, or sponsored by Meta Platforms, Inc.** +It is an independent, unofficial project, inspired by an internal tool at Meta called BunnyLol. +**Not affiliated with, endorsed by, or sponsored by Meta Platforms, Inc.** + +Manifest V3. 93 shortcuts ship across 180 keywords, and every one of them can be renamed, re-keyed, +moved, switched off or deleted. No runtime dependencies, no network requests of its own, and nothing +leaves your machine. See [PRIVACY.md](PRIVACY.md). + +## A look inside + +Pick the built-in shortcut packs you actually want: + +![BunnyLol first-run shortcut pack picker](docs/images/welcome.png) + +Browse, filter, edit and switch shortcuts on or off from one page: + +![BunnyLol shortcut manager showing the AI shortcuts](docs/images/shortcuts.png) -Manifest V3. No dependencies at runtime. It makes no network requests of its own, and nothing leaves -your machine. See [PRIVACY.md](PRIVACY.md). +New shortcuts use the same resolver as the address bar, so the form can show the real destination +before you save: -## Install from source +![BunnyLol shortcut editor with a live MDN preview](docs/images/editor.png) -There is no store listing yet. Build it and load it unpacked: +The toolbar popup gives you autocomplete when you do not want to leave the current page: + +

+ BunnyLol toolbar popup with GitHub autocomplete +

+ +## Install + +### From the Chrome Web Store + +Not listed yet. The link goes here once the listing is live. + +### From source + +Node 22.13 (pinned in `.nvmrc`, so `nvm use` picks it up, and in `engines.node`) and pnpm, which is +pinned by `packageManager` in package.json. Do not run `npm install`: it creates a second lockfile. ```bash pnpm install @@ -38,26 +74,7 @@ same way: the MV3 manifest, the service worker and the redirect rules all behave fork that routes the omnibox itself may not hand over address-bar navigations. There the toolbar popup and `bl` + Tab still work. -## First run - -The first install opens a **welcome screen**. It asks one question: which packs of shipped shortcuts -do you want? Only **Search**, **Developer** and **AI** are ticked. Every other pack is offered -unticked, and its shortcuts start switched off: **Google**, **Microsoft**, **Social**, -**Productivity**, and, under *Optional packs*, **Purdue**. Purdue is one university's tooling, dead -weight for everyone else. You can close the tab without answering and keep that starter set. The -pick is written before the screen opens, so it is already live. - -Nothing there is final. You can rename, move, switch off or delete every shortcut afterwards. To -change the pick later, go to **Settings → Sections → Shortcut packs → Choose shortcut packs…**. That -opens a **Shortcut packs** screen of its own: the same cards, with Save and Cancel in place of the -first-run text. That is how you turn a pack on later. To see the welcome screen itself again, on an -empty profile, use **Settings → Data → Start over**. - -Note what saving a pick means, on either screen. It turns *on* every shipped shortcut in the packs -you tick, including ones you had switched off by hand. It turns off the ones in the packs you leave -unticked. It never touches shortcuts you made yourself. - -## How triggering works +## The one rule **The first word wins.** If the first word you type in the address bar is a keyword you have, it is a command. Always, with no heuristics. `c programming tutorial` opens Claude with that prompt. `pr @@ -79,7 +96,39 @@ wagon price**, escape it: `\g wagon price`. Do the same for `pr` (`\pr firms in anything else. The escape character is stripped before the search, so it never reaches the engine as a search term. It works on every surface, because they all run the same resolver. -### The three ways in +Curating a list of "safe" words on your behalf was tried and dropped. It is an endless tail: a large +fraction of the keywords that would have stayed eligible could still hijack some plausible English +query, and blocking those only surfaced the next tier (`td`, `iss`, `bs`, `gs`). Worse, it made +behaviour unpredictable in the one place where predictability matters. So the trade is explicit. +Every keyword fires, every time, and the escape hatch has to be flawless. If one keyword still +annoys you in practice, exempt exactly that one (below). Nothing ships exempted. + +Under the hood, BunnyLol tags its own searches with a `blpass=1` parameter and registers a +top-priority `allow` rule for anything carrying that tag. Without it, an escaped search would land +on `google.com/search?q=gh+foo`, which is the very URL the redirect rule was built to catch, and you +would bounce back into the shortcut you were escaping. If you see that parameter in an address bar, +it is BunnyLol's, and it does nothing. + +## First run + +The first install opens a **welcome screen**. It asks one question: which packs of shipped shortcuts +do you want? Only **Search**, **Developer** and **AI** are ticked. Every other pack is offered +unticked, and its shortcuts start switched off: **Google**, **Microsoft**, **Social**, +**Productivity**, and, under *Optional packs*, **Purdue**. Purdue is one university's tooling, dead +weight for everyone else. **Skip**, or closing the tab, keeps that starter set. The pick is written +before the screen opens, so it is already live. + +Nothing there is final. You can rename, move, switch off or delete every shortcut afterwards. To +change the pick later, go to **Settings → Sections → Shortcut packs → Choose shortcut packs…**. That +opens a **Shortcut packs** screen of its own: the same cards, with Save and Cancel in place of the +first-run text. To see the welcome screen itself again, on an empty profile, use **Settings → Data → +Start over**. + +Note what saving a pick means, on either screen. It turns *on* every shipped shortcut in the packs +you tick, including ones you had switched off by hand. It turns off the ones in the packs you leave +unticked. It never touches shortcuts you made yourself. + +## The three ways in Google stays your real default search engine. Nothing about your browser settings changes. @@ -101,54 +150,29 @@ Google stays your real default search engine. Nothing about your browser setting - **Omnibox keyword (fallback).** Type `bl`, press **Tab**, then your command. This path does not use the redirect rules at all. So it is the safety net if interception behaves differently in your - browser, and it is where an exempted keyword (below) still works. + browser, and it is where an exempted keyword still works. - **Toolbar popup.** Click the BunnyLol icon for a command bar with autocomplete over the same registry. Handy when you are already on a page. All of them run the same resolver, so a shortcut behaves the same no matter how you invoke it. -### Why an escape hatch and not a list of "safe" words - -BunnyLol exempts nothing by default. The exemption list is yours and it starts empty, so `map`, -`news`, `mail` and `so` are intercepted like every other keyword until you say otherwise. - -Curating that list on your behalf was tried and dropped, because it is an endless tail. A large -fraction of the keywords that would have stayed eligible could still hijack some plausible English -query, and blocking those only surfaced the next tier: `td`, `iss`, `bs`, `gs`. Worse, it made -behaviour unpredictable in the one place where predictability matters. You could not tell by looking -whether a keyword would fire. - -So the trade is explicit. Every keyword fires, every time, and the escape hatch has to be flawless. -Under the hood, BunnyLol tags its own searches with a `blpass=1` parameter, and registers a -top-priority `allow` rule for anything carrying that tag. Without the tag, an escaped search would -land on `google.com/search?q=gh+foo`. That is exactly the URL the redirect rule was built to catch, -so you would bounce back into the shortcut you were escaping. The same tag is why the commands that -*are* searches, `g` and `ddg`, reach the engine once instead of looping through the dispatch page. -If you see that parameter in an address bar, it is BunnyLol's, and it does nothing. - ### Exempting a keyword you keep tripping over -Say one keyword annoys you in practice: "I search for *maps of X* constantly." Exempt it. Settings -has an **Exempt keywords** card. Type the keyword, press Add, and the address bar skips it from then -on. Remove the chip to get interception back. +Settings has an **Exempt keywords** card. Type the keyword, press Add, and the address bar skips it +from then on. Remove the chip to get interception back. An exemption costs the keyword nothing but address-bar interception. It still resolves through `bl` -+ Tab and the toolbar popup, where you have already said you mean a command. Nothing ships exempted. ++ Tab and the toolbar popup, where you have already said you mean a command. ### Seeing which command fired **Confirm before opening a shortcut** sits at the foot of the **Search interception** card, and is off by default. With it on, the dispatch page stops instead of redirecting. It names the keyword -that fired and the shortcut it matched, prints the whole URL it is about to open, and waits for you. +that fired and the shortcut it matched, prints the whole URL it is about to open, and waits. **Open github.com** goes there, and it holds the focus, so Enter is enough. **Search for what you typed instead** runs the escaped search. There is no timer: nothing moves until you answer, and -closing the tab is a third answer. - -It is opt-in because an ordinary dispatch must not stop to ask a question. Nothing rendered on the -dispatch page survives the redirect, and showing the confirmation *on the destination* would need a -content script injected into every site you visit, which this feature does not justify. Turn it on -while you are learning the keywords, then turn it off. +closing the tab is a third answer. Turn it on while you are learning the keywords, then turn it off. ## What ships @@ -159,27 +183,29 @@ A bare keyword goes to the site's home page. Adding arguments does the smart thi | `gh facebook/react` | `github.com/facebook/react`, the repo itself, not a search | | `gh` | GitHub home | | `c explain monads` | Claude with the prompt already filled in (`gpt` for ChatGPT, `gem` for Gemini) | -| `rd rust` | `reddit.com/r/rust` | | `npm zod` | The `zod` package page, skipping npm's search results | +| `gr project hail mary` | The book on Goodreads | +| `def ineffable` | The dictionary entry | +| `rd rust` | `reddit.com/r/rust` | | `td groceries` | Searches your Todoist tasks; `tda groceries` is the one that creates one | | `zoom 1234567890` | Joins that meeting; `zoom h6 recorder review` searches instead of building a dead join link | -| `ups 1Z…` | Tracks that parcel; anything that is not a tracking number searches | | `track 9400…` | Reads the carrier off the number (UPS, USPS, FedEx or DHL) and opens its tracking page | -| `def ineffable` | The dictionary entry | +| `set` | The options page. `bl` opens the shortcut list, `add` opens the new-shortcut form | | `\gh` *anything* | Escape hatch: a leading `\` (or `=`) forces a plain search instead of a shortcut | Some of those rows are not in the starter set. `rd` is in the **Social** pack, and `td`, `tda`, -`zoom`, `ups` and `track` are in **Productivity**. Both packs start switched off, which means their -rows are under **Hidden shortcuts** rather than missing. Tick the packs on the welcome screen, or -later from **Settings → Sections → Shortcut packs**. Everything else in the table ships on. +`zoom` and `track` are in **Productivity**. Both packs start switched off, which means their rows +are under **Hidden shortcuts** rather than missing. Tick the packs on the welcome screen, or later +from **Settings → Sections → Shortcut packs**. Everything else in the table ships on. -The options page has the full list: every alias, grouped, with a worked example per row. Use the -filter box there rather than memorising it. +That is a sample. The full registry is [`src/lib/commands.ts`](src/lib/commands.ts), which is plain +data: 93 shortcuts, 180 keywords, one row each with its destination and a worked example. The +options page renders the same list with a filter box, which is the better way to browse it. ## Managing shortcuts Open the options page from the popup, from `set` in the address bar, or by right-clicking the -toolbar icon → **Options**. +toolbar icon → **Options**. It has three tabs: **Shortcuts**, **New shortcut** and **Settings**. - **Shortcuts** lists everything, grouped, with a live filter (press `/`). Groups collapse, and each browser profile remembers its own state. Typing in the filter expands them until you clear it. @@ -257,7 +283,7 @@ first if you want your shortcuts back. | Card | What is in it | |---|---| -| **Default Usernames** | Your GitHub username (used by `gh me`, `pr`, `iss`); where an unmatched query goes (any template with `{q}`, with Kagi and Brave Search one click away, or paste your own); and your Google account index for `/u/N/` URLs | +| **Default Usernames** | Your GitHub username, which `gh me` resolves to; the fallback search engine, where an unmatched query goes (Google, Bing, DuckDuckGo, Kagi and Brave Search are one click away, or paste any template containing `{q}`); and your Google account index for `/u/N/` URLs | | **Sections** | Create, rename and delete sections, one row each; and **Choose shortcut packs…**, which opens the packs screen | | **Search interception** | Which engines are intercepted (untick them all to leave every search alone), the dispatch-page URL to paste in as a custom search engine, and **Confirm before opening a shortcut** | | **Exempt keywords** | The keywords the address bar leaves alone | @@ -286,22 +312,26 @@ worked. |---|---| | **A keyword typed in the address bar just searches for it.** | The redirect rules are not registered. Check the rule-status pill and click **Re-sync**. The rules embed the extension's ID, so loading `dist/` from a new path changes the ID and needs a re-sync. Use `bl` + Tab meanwhile. | | **Nothing happens, or the dispatch page shows an error.** | Open `chrome://extensions`, find BunnyLol and click **service worker** for its console. Rule-sync failures, storage errors and omnibox activity are logged there. The dispatch page prints the reason it could not resolve rather than hanging. | -| **An AI shortcut opens the site but does not carry my prompt.** | Those prefill parameters change without notice, and there is no settings card for them. Two ways round it without a rebuild. Make your own shortcut with the working URL as its **Search URL** and switch the shipped one off; a shortcut you create sends the prompt where you put `{q}`. Or export your JSON from **Settings → Data**, add the provider template to `settings.aiTemplates` (`{"claude": "https://claude.ai/new?q={q}"}`, keyed by `claude`, `chatgpt`, `gemini` or `claudecode`, and it must contain `{q}`), and import the file back with **Replace everything**, which is the choice that takes a file's settings. | -| **A shortcut collides with something I actually search for.** | That is by design: the first word is always a command. Prefix it with `\` or `=`. If it happens constantly with one keyword, exempt it in **Settings → Exempt keywords**, or rename, switch off or delete the shortcut. | +| **An AI shortcut opens the site but does not carry the prompt.** | Those prefill parameters change without notice, and there is no settings card for them. Two ways round it without a rebuild. Make your own shortcut with the working URL as its **Search URL** and switch the shipped one off; a shortcut you create sends the prompt where you put `{q}`. Or export your JSON from **Settings → Data**, add the provider template to `settings.aiTemplates` (`{"claude": "https://claude.ai/new?q={q}"}`, keyed by `claude`, `chatgpt`, `gemini` or `claudecode`, and it must contain `{q}`), and import the file back with **Replace everything**, which is the choice that takes a file's settings. | +| **A shortcut collides with a phrase you actually search for.** | That is by design: the first word is always a command. Prefix it with `\` or `=`. If it happens constantly with one keyword, exempt it in **Settings → Exempt keywords**, or rename, switch off or delete the shortcut. | | **`g wagon price` searched for "wagon price".** | Working as intended. `g` is the "search Google" command, and its argument is what gets searched. Use `\g wagon price` for the literal phrase. | -| **One shortcut only works from the popup or `bl` + Tab.** | Either you exempted it, or its alias cannot be embedded in a URL pattern, or the pill reports it as dropped because Chrome refused the pattern or the rule budget is full. Interception needs lowercase ASCII letters, digits, `_` and `-`, starting with a letter, digit or `_`, and at most 32 characters. All three cases leave the shortcut working everywhere else. | -| **A shipped shortcut points at the wrong place for me.** | Edit it. The Purdue shortcuts in particular read their host off the URL on the row, so rebinding one to your own institution works. | +| **One shortcut works only from the popup or `bl` + Tab.** | Either it is exempted, or its alias cannot be embedded in a URL pattern, or the pill reports it as dropped because Chrome refused the pattern or the rule budget is full. Interception needs lowercase ASCII letters, digits, `_` and `-`, starting with a letter, digit or `_`, and at most 32 characters. All three cases leave the shortcut working everywhere else. | +| **A shipped shortcut points at the wrong place.** | Edit it. The Purdue shortcuts in particular read their host off the URL on the row, so rebinding one to another institution works. | ## Development ```bash pnpm dev # vite build --watch +pnpm lint # eslint + prettier --check pnpm typecheck # tsc --noEmit pnpm test # vitest pnpm build # icons + typecheck + vite build -> dist/ pnpm package # build, then release/bunnylol-.zip for the Web Store ``` +`pnpm lint && pnpm typecheck && pnpm test && pnpm build` is the gate every commit has to pass, and +it is what CI runs. The UI is vanilla TypeScript and CSS with no runtime dependencies. + The resolver (`src/lib/resolve.ts`) is pure and free of `chrome.*`, so the dispatch page, the omnibox, the popup and the tests all share one code path. @@ -310,16 +340,25 @@ also holds the HTML previews the design was approved from. The pages ship the [Inter](docs/fonts.md) variable font as a bundled file rather than a webfont request, so rendering the extension's own pages needs no network. +## Contributing + [CONTRIBUTING.md](CONTRIBUTING.md) covers the setup, the checks a change has to pass, and how to add a command. [AGENTS.md](AGENTS.md) is the architecture note. Read its invariants before you change routing or validation: every one of them is a bug that already shipped once. +[CODE_OF_CONDUCT.md](CODE_OF_CONDUCT.md) applies. Released versions are listed in +[CHANGELOG.md](CHANGELOG.md). -## Privacy +## Privacy and security No collection, no transmission, no analytics, no telemetry, no remote code, and no network requests -of its own. Everything is one JSON value in `chrome.storage.local` on your device. Full statement: +of its own. Your shortcuts and settings are one JSON value in `chrome.storage.local` on your +device, and the only other thing stored is which groups you have folded. Full statement: [PRIVACY.md](PRIVACY.md). +BunnyLol holds redirect rules on three search engines, so a routing bug here is a browsing-data bug. +Report a vulnerability privately through GitHub's Security tab rather than as an issue: +[SECURITY.md](SECURITY.md). + ## License [MIT](LICENSE). The bundled Inter font is licensed separately under the SIL Open Font License 1.1. diff --git a/SECURITY.md b/SECURITY.md index 90ea105..03c9252 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -22,7 +22,7 @@ the surfaces that matter most for a security review: - The passthrough and escape path (`FORCE_SEARCH_PREFIXES` in `src/lib/types.ts`) that lets a user force a plain search. - URL construction in `src/lib/handlers.ts`. -- The JSON import parser in `src/lib/storage.ts`. +- The JSON import parser in `src/lib/storage/parse-import.ts`. - Anything that could put untrusted text into the DOM as markup rather than as text. diff --git a/docs/chrome-web-store.md b/docs/chrome-web-store.md deleted file mode 100644 index 16ff6de..0000000 --- a/docs/chrome-web-store.md +++ /dev/null @@ -1,131 +0,0 @@ -# Chrome Web Store submission - -Everything the dashboard asks for, written down once, so a submission is a copy-paste rather than a -fresh act of composition. The strings below are the answers, word for word. If the code stops -matching one of them, change the code or change this file. Do not soften a justification to fit. - -This repo produces the manifest, the release zip (`pnpm package`), the privacy policy -([PRIVACY.md](../PRIVACY.md)) and the listing icon (`store/icon128.png`). It does not produce -screenshots or listing copy. See [Assets](#assets). - -## Category - -**Productivity** (Workflow & Planning). - -## Single purpose - -> Resolve user-defined keyword shortcuts typed into the address bar into their destination URL. - -## Permission justifications - -One per declared permission, in the order the dashboard lists them. - -**`storage`** - -> Stores the user's keyword shortcuts and settings locally on the device (chrome.storage.local); -> nothing is uploaded. - -**`declarativeNetRequest`** - -> Registers dynamic redirect rules that rewrite an address-bar search on the three supported -> engines into the extension's local dispatch page, so a typed keyword resolves without any request -> reaching the search engine. - -**Host permissions**: `https://www.google.com/*`, `https://www.bing.com/*`, -`https://duckduckgo.com/*` - -> declarativeNetRequest redirect rules only apply to URLs the extension has host access to. -> www.google.com, www.bing.com and duckduckgo.com are the only engines whose result URLs are -> rewritten; no content scripts are injected and no page content is read. - -`omnibox` is a manifest key, not a permission. The dashboard will not ask about it, and it does not -appear on the install prompt. - -`tests/manifest.test.ts` pins the declared list. It derives the host permissions from -`SEARCH_ENGINES` rather than restating them. Adding a permission fails that test first, which is the -point: a new permission is a new review. - -## Remote code - -**No.** - -Evidence, if review challenges it: there is no `eval`, no `new Function`, no `importScripts`, and no -CDN or other remote script reference anywhere in `src/`, `go.html`, `options.html` or `popup.html`. -There is no `fetch` or `XMLHttpRequest` of any kind. The only bundled binary assets are the icons -and one self-hosted font (`public/fonts/InterVariable.woff2`, see [fonts.md](fonts.md)). Everything -that runs ships in the package. - -## Privacy practices - -- **Data collection:** tick nothing. The extension collects, transmits and stores nothing off the - device. The whole persisted state is one JSON value under `bunnylol.state.v1` in - `chrome.storage.local`, plus a session-lifetime rule-status cache in `chrome.storage.session`. -- **Limited use:** certify all three statements. Nothing is sold, transferred or used for anything - but the single purpose above, because nothing leaves the machine. -- **Privacy policy URL:** the published URL for [PRIVACY.md](../PRIVACY.md). Use the GitHub Pages - copy, or the raw file over https. The field requires a URL, not a file. - -## Search-behaviour disclosure - -Undisclosed modification of search behaviour is a rejection trigger, and this extension does rewrite -search navigations. Put this in the listing's detailed description. The wording is fixed here so it -cannot be softened later: - -> BunnyLol does not change your default search engine. It watches address-bar navigations to -> Google, Bing and DuckDuckGo and, when the first word of what you typed matches one of your -> keywords, redirects locally to the extension's own dispatch page instead of loading the results -> page. Everything else searches normally. Put \ or = in front of anything you want searched as -> plain text. Interception can be turned off per engine, or entirely, in the extension's settings. - -## Non-affiliation - -Also in the detailed description: - -> BunnyLol is an independent, unofficial project inspired by a bunnylol-style command bar. Not -> affiliated with, endorsed by, or sponsored by Meta Platforms, Inc. - -Avoid the word "clone" in listing copy specifically. It invites the impersonation read that the line -above exists to close. Have a fallback name ready in case review objects to the name itself. - -## Assets - -- **Listing icon:** `store/icon128.png`. It is the same art as the toolbar icon at 96px, centred in - a 128px frame. `public/icons/icon128.png` is deliberately full-bleed for the toolbar and looks - wrong on a listing card. `scripts/gen-icons.mjs` generates both. `store/` sits outside `public/`, - so it is never copied into `dist/` or the release zip. -- **Screenshots are a hard submission blocker, and this repo does not produce them.** At least one - 1280x800 PNG is required. A suggested set: the address bar mid-type, the options page showing the - shortcut list and the rule-status pill, and the toolbar popup. A 440x280 small promo tile is - needed to be eligible for featuring. -- Keep every listing asset out of `dist/`, so none of it reaches the upload. - -## Upload checklist - -1. Bump `version` in `public/manifest.json` **and** `package.json` in the same commit - (`tests/manifest.test.ts` fails if they disagree). The store requires each upload to be strictly - higher than the last. -2. Add the release to `CHANGELOG.md`. -3. `pnpm typecheck && pnpm test && pnpm build`. -4. `pnpm package` → `release/bunnylol-.zip`. -5. Confirm the zip: `unzip -l release/bunnylol-.zip` must show `manifest.json` at the top - level with no `dist/` prefix, and no `*.map` entry anywhere. -6. Upload that zip. Paste the single-purpose statement, the permission justifications, and the - two paragraphs above into the listing and the privacy tab. - -## Known review risks - -| Risk | Mitigation | -|---|---| -| The extension rewrites search-engine navigations | The disclosure paragraph above, plus per-engine and global off switches in Settings | -| The name is Meta-adjacent | The non-affiliation line, and no use of "clone" in listing copy | -| A first interception with no explanation attached | The first-run picker states the first-word rule and the escape prefixes before any shortcut fires | - -## Appendix: not in scope, drafted because review will ask - -The two paragraphs under [Search-behaviour disclosure](#search-behaviour-disclosure) and -[Non-affiliation](#non-affiliation) are listing copy, which this repo deliberately does not own. -They are written out here for two reasons. Both are answers to questions that manual review reliably -asks. And both are claims about how the code behaves: the first word of an address-bar query is -always a command when it matches a registered keyword, a leading `\` or `=` forces a plain search, -and interception is per-engine. Someone answering review under time pressure should not have to -re-derive them from `src/lib/resolve.ts`. diff --git a/docs/fonts.md b/docs/fonts.md index 1b9a5be..cdc39ba 100644 --- a/docs/fonts.md +++ b/docs/fonts.md @@ -62,11 +62,10 @@ The font exists twice on purpose: - `design/fonts/`, so `design/`'s previews render standalone in a browser, with no build. - `public/fonts/`, so the extension ships it. -They must stay byte-identical. `tests/tokens.test.ts` asserts that by sha256-ing the bytes of both -files. It also asserts that the shipped copy hashes to the value recorded in the table above. So -neither the drift that actually happens (one copy updated to a new Inter release and the other left -behind) nor a silent edit to both can pass. Update the table when the release changes: the test -reads the hash straight out of it. +They must stay byte-identical, and the shipped copy must hash to the value recorded in the table +above. A test asserted both by sha256-ing the bytes until the suite was cut back to behaviour a user +meets; check it by hand (`shasum -a 256 design/fonts/* public/fonts/*`) when you change the release, +and update the table. ### Known issue in the design previews diff --git a/docs/handoff.md b/docs/handoff.md deleted file mode 100644 index 85c0286..0000000 --- a/docs/handoff.md +++ /dev/null @@ -1,328 +0,0 @@ -# Handoff: the v1.1.0 open-source release - -For the next agent or person who picks this branch up. It is a map, not a spec. `AGENTS.md` is -still the authority on conventions and invariants, and it wins over anything written here. - -This file may be deleted before the release branch merges. It describes work in flight. - -## 1. What this is and where things are - -BunnyLol is a Manifest V3 Chrome extension that turns the address bar into a command line. This -branch takes it from a personal tool to something publishable. - -| Thing | Where | -|---|---| -| Repo | `github.com/ion05/bunnylol` | -| Branch | `ion05/open-source-redesign-bunnylol`, tracking `origin` | -| Pull request | https://github.com/ion05/bunnylol/pull/11, open against `master` | -| Agent context | `AGENTS.md` at the repo root. `CLAUDE.md` just points at it. | -| Human contributor guide | `CONTRIBUTING.md`, the same material for a person | -| User docs | `README.md` | -| Design system | `design/`, with `design/README.md` as its authority | - -The planning material lives in `.context/` in this workspace. `.context/` is in `.gitignore`, so -it is workspace-local and never reaches the repo or the PR. If you are working from a fresh clone, -you will not have it. Read it here: - -- `.context/attachments/2VKc9L/plan.md` is the master plan. Its decisions table and its "Calls - made" list are binding. -- `.context/brief.md` is the shared brief every implementing agent read first. -- `.context/units/pr1.md` through `pr12.md` are one spec per commit layer. -- `.context/design-feedback.md` collects the gaps found in the owner's design bundle. -- `.context/pr-body-draft.md` is the text the PR body was written from. -- `.context/planner/*.md` are the original planner and audit dumps. Large. Grep them, do not read - them end to end. - -When `AGENTS.md` and `.context/` disagree, `AGENTS.md` wins. It is the file that ships. - -## 2. The owner's binding decisions - -These were confirmed with the owner. Do not reopen them without asking. - -- **Look.** Raycast style: dense, keyboard first, compact rows, flat surfaces. One accent, - `#e1ab76` sand. Inter bundled as a local woff2. Follow the system light and dark setting, with - no toggle in the UI. -- **Design review.** Claude Design, not screenshots. Only the approved system got implemented. -- **Licence and name.** MIT. The name stays "BunnyLol". The README says it is unofficial and not - affiliated with Meta. -- **Storage.** `chrome.storage.local` only. -- **Purdue.** Stays, as an opt-in pack that is off after the first-run pick. The Brightspace and - Gradescope handlers read their host off the command's own URL, so rebinding one to another - school works. -- **Tooling.** GitHub Actions only. No ESLint, no Prettier, **no new dependencies of any kind**, - devDependencies included. -- **Chrome Web Store.** Code and packaging only. No screenshots and no listing copy in this repo. -- **Version.** 1.1.0 in `package.json` and `public/manifest.json`. Export format 2, with a reader - for format 1. -- **Onboarding.** Packs only. On install: write the starter pick, then sync the rules, then open - the welcome tab. Purdue is shown unticked. Meta shortcuts (`bl`, `add`, `set`) are always on and - never listed. Continue performs exactly one write. Closing the tab keeps what is already live. -- **Unified shortcuts.** Every shortcut has Edit, on/off and Delete, shipped or not. One form. - Reset refills the form from the shipped definition, or from the last save for a user shortcut. - Deleted shipped shortcuts come back from Settings. `handler`, `provider` and `builtin` are never - editable. -- **Collapse.** Expanded by default. Remembered in `localStorage`, never in settings. A live - filter force-expands. -- **Sections.** Any shortcut into any section. Shipped sections can be renamed. Deleting a user - section moves its members to My shortcuts. -- **Delivery.** One branch and one PR, not a stack. One architectural layer per commit. Every - commit green on its own. - -### The one deviation - -The accent is two tokens. `#e1ab76` is 2.04:1 on white, so it cannot legally be text, a focus ring -or a state indicator in light mode. `--accent` is a fill only. `--accent-text` is the same hue and -saturation at half lightness and carries links, the keyword mark, the focus ring and the active nav -underline. In dark mode the raw sand is readable and does both. `tests/tokens.test.ts` forbids -`color: var(--accent)`. - -### Smaller calls worth knowing - -- The `media` category was removed. `normalizeCategory` coerces unknown ids to `custom`. -- `enabledCategories === null` is the only "never onboarded" signal. -- An existing user is never shown the picker unasked. Settings links to it. -- `edits` entries are for shipped ids only. User shortcuts are edited in place. -- Inter ships whole, with `unicode-range` limiting rasterisation. No subsetting tool was added. -- Numeric counts of commands and tests were removed from the docs, not refreshed. Keep them out. - -## 3. What was built, layer by layer - -Each line is one commit or one small group. The commit bodies explain why. Read them with -`git log --format='%h %s%n%b' 6493ef2..HEAD`. - -| Layer | What landed | Key files | -|---|---|---| -| Repo hygiene | MIT licence, privacy, security and conduct files, changelog, CI, issue and PR templates, the removed-commands pack | `LICENSE`, `PRIVACY.md`, `SECURITY.md`, `CODE_OF_CONDUCT.md`, `.github/`, `extras/packs/` | -| Lib hardening | `syncRules` serialized with one trailing coalesced slot; the Purdue handlers read their host off the command | `src/lib/dnr.ts`, `src/lib/handlers.ts`, `tests/sync-rules.test.ts` | -| Shared helpers | Verbatim lifts out of the UI surfaces | `src/lib/text.ts`, `src/lib/draft.ts`, `src/ui/dom.ts` | -| Design system | The approved bundle, committed as approved; both sheets moved onto the tokens; Inter bundled | `design/`, `src/options/options.css`, `src/popup/popup.css`, `public/fonts/`, `tests/tokens.test.ts` | -| Data model I | Stable ids, section validators, the edit and delete override layer, export format 2 | `src/lib/overrides.ts`, `src/lib/storage.ts`, `src/lib/validate.ts` | -| Data model II | Open sections, the pack algebra, import merge, the adversarial suite | `src/lib/onboarding.ts`, `src/lib/merge-import.ts`, `tests/overrides-security.test.ts` | -| Options split | The page monolith became router, store, models and views. A pure move. | `src/options/router.ts`, `store.ts`, `dom.ts`, `rule-status.ts`, `model/`, `views/` | -| Features | One edit form for every shortcut, collapsible groups, the sections card, import copy | `src/options/views/form.ts`, `browse.ts`, `settings.ts`, `data.ts`, `src/options/model/collapse.ts` | -| Onboarding | The `#welcome` picker and the install-time starter pick | `src/options/views/welcome.ts`, `src/options/model/welcome.ts`, `src/lib/install.ts` | -| Restyle | Browse, topbar and status; then form, settings, data, welcome, popup and the dispatch page; then the icon painted from the tokens plus the store tile | `src/options/options.css`, `src/popup/popup.css`, `go.html`, `src/go/go.ts`, `scripts/gen-icons.mjs` | -| Release | Manifest narrowed to `go.html`, version 1.1.0, a deterministic zip, docs rewritten, invariants 15 to 17 recorded | `public/manifest.json`, `scripts/package.mjs`, `tests/manifest.test.ts`, `README.md`, `AGENTS.md` | - -### Added after the PR opened - -- The welcome picker's pack cards unfold to list the shortcuts a tick turns on. -- Row actions are icons in the order Edit, Delete, then the on/off switch. -- The "Turned on N packs" notice and the "Intercepting N keywords" status headline were removed. -- `track `, also `pkg`, reads the carrier off the shape of the number and opens that - carrier's own page. A `dhl` shortcut joins `ups`, `fedex` and `usps`. -- A writing pass: every doc and every string a user reads was simplified, and every em dash - removed. No tracked file outside `design/` has one now. -- The welcome screen intro was rewritten and its explainer paragraphs trimmed. -- **Start over** in Settings, under Data. It deletes the state key and reruns the install path, so - the welcome flow can be tested again in a used profile. -- Group counts and the toolbar count now say how many of the rows they cover are on. -- Switched-off shortcuts left their sections. They are drawn under one folded "Hidden shortcuts" - group at the foot of the Shortcuts page, and the per-row "off" badge went with them. -- Settings lost three cards. "Restore shipped shortcuts" is gone, so deleting a shipped shortcut is - no longer undoable one shortcut at a time; "AI prompt templates" is gone while - `settings.aiTemplates` still works through an imported file; and "Default AI" is gone along with - `Settings.defaultAi` and the `?` command, both deleted outright. -- `#packs` is a route of its own. Settings links there rather than reopening `#welcome`. -- The dispatch confirmation waits for the user instead of running a 1.2s timer. - -## 4. How the work was run, and the rules to keep - -### The loop - -1. One subagent implemented one unit, from its spec in `.context/units/`. Opus for code, Sonnet - for mechanical moves and docs. -2. Three adversarial reviewers then read it: one for spec completeness, one for correctness and - the `AGENTS.md` invariants, one for conventions. The correctness reviewer used mutation - testing: break the code the test guards, confirm the test goes red, put it back. A test that - stays green either way is not a test. -3. A fix round folded the findings back into the same commit. Nothing was left for later. -4. The commit was then checked out on its own and put through the gate. - -### The gate - -```bash -pnpm typecheck && pnpm test && pnpm build -``` - -All three, green, on **every** commit, not just at the tip. That is a project convention, and it -is checkable: `git checkout ` and run it. - -CI runs the same three steps, plus two more: - -- `git diff --exit-code -- public/icons store`, because `pnpm build` repaints the icons from - `design/tokens.css` and the PNGs are committed. Changing the generator or the accent without - committing the result shows up as a dirty tree. -- `node scripts/package.mjs`, because the packer has no test and writes a binary nothing else - reads. Running it over a real build is the cheap guard. - -### Rules any new agent must keep - -- Run the gate before you commit, and again after any fix. -- Stage by explicit path. Never `git add -A`. The tree often holds another agent's work. -- Commit subjects are imperative, at most 72 characters, with no trailing period. The body says - what changed and why. -- No new dependencies, devDependencies included. -- No em dashes in any project-owned text. -- CSS uses `design/tokens.css` custom properties and the class vocabulary in - `design/components.css`. No literal hex, no raw `font-size: Npx`, and never - `color: var(--accent)`. `tests/tokens.test.ts` enforces all of it. -- `src/lib` and `src/options/model` must import cleanly under vitest's `environment: node`. No - `document` and no `chrome.*` at module scope. -- Comment only where the reason is non-obvious. Carry existing comments across moves verbatim. -- User text reaches the DOM only through `textContent` or `createElement`. -- Do not "fix" a failing test from the invariants list. Fix the code. - -## 5. State of play - -### Done - -Everything in the table in section 3, plus everything under "Added after the PR opened". The -branch is pushed and the PR is open. The tree was clean at the time of writing. - -### Owed by the owner - -No browser was available in the build sessions, so nothing below could be done by an agent. - -1. Load `dist/` unpacked and screenshot `options.html#help` in light and dark for the PR body. -2. Fresh profile: `#welcome` opens with Search, Developer and AI ticked and everything else - unticked. Close the tab. `bs cs251` should search normally and `gh facebook/react` should land - on the repo. Rule status green. -3. Edit `gh`, rename it and change its URL, and check the `modified` badge. Reset, Save, badge - gone. Delete `gh` and check it leaves the list and the address bar; there is no per-shortcut - restore any more, so Settings → Data → Reset to defaults is what brings it back. Switch `bl` - off and on, and watch the row move to Hidden shortcuts and back to its section. -4. Create a section from the form, move `gh` into it, rename a shipped section, delete the new - one and confirm its member returns to My shortcuts. "Restore default name" should refuse when - another section already carries that label. -5. Collapse two groups and reload. Still collapsed. Type in the filter, they expand. Clear it, - they collapse again. Collapse all and Expand all. -6. Export, then import with Merge, and check the dialog names edits, deletes and sections by - label. A format 1 export file with a `media` shortcut in it must still import. -7. Popup: `gh f` highlights the keyword and Enter navigates. The selected row is sunken. The - toolbar icon is legible on light and dark toolbars. -8. One real intercepted search from Google, Bing and DuckDuckGo, to check the narrowed - web-accessible resources. A missing resource fails silently. Then the dispatch confirmation, - which waits for a click, and the error page. -9. `pnpm package`, then `unzip -l release/bunnylol-1.1.0.zip`: `manifest.json` at the root and no - `.map` files. Drag the zip into the Web Store dashboard once, to confirm the hand-rolled zip is - accepted. -10. Compare `options.html` at `#help`, `#new` and `#settings`, plus `#welcome`, the popup and - `go.html`, against `design/canvas/*.dc.html` in both schemes. -11. Web Store screenshots. At least one 1280x800 PNG is a hard submission blocker, and this repo - deliberately does not produce them. See `docs/chrome-web-store.md` under "Assets". -12. The contract gaps in `.context/design-feedback.md`. They are edits to `design/`, which is the - approved bundle and was implemented as approved. They need a design review, not a passing fix. - -### Open decisions - -- **The bare `track` landing page.** With no number to read, `track` goes to `parcelsapp.com`, - a third party. It is the one page that accepts every carrier's number. Nobody has confirmed - that a third-party landing page is acceptable. -- **`track` and `pkg` as keywords.** Both are ordinary English first words. The first word always - wins, so both will hijack some real searches. The escape prefixes are the answer, but the owner - may still want to rename or drop one. -- **The omnibox middle dot.** The em dash separator became `·` to match the dispatch confirmation - and the status line. A comma would have read as part of the shortcut's name. Nobody has seen it in a - real omnibox yet. -- **Whether this file stays.** It documents work in flight. It is probably deleted before the - merge. - -### A bug report that could not be reproduced - -Someone reported that Continue on the welcome screen turns on all packs. It was investigated end -to end and does not reproduce: `applyCategoryPick` projects the pick into `Overrides.disabled` and -switches the unticked packs off. The likely cause is what the Shortcuts page then showed. It -listed every pack at full strength, with the off rows only dimmed, so a correct pick read as a pick -that had done nothing. The group counts, and then the Hidden shortcuts group that takes the off rows -out of their sections entirely, are the answer to that. If the report comes back, check -`applyCategoryPick` in `src/lib/onboarding.ts` and the grouping in `src/options/model/browse.ts` -before anything else. - -## 6. Known non-obvious facts that bit us - -These are the things reviewers caught. Each looks like reasonable code. - -- **The meta shortcuts ship a relative URL.** `bl`, `add` and `set` point at `options.html#…`, and - the dispatch page absolutises it. Applying `withScheme` unconditionally on save turned a - no-change Save into a stored `https://options.html#help` that opened nothing, permanently. See - `keptUrl` in `src/lib/draft.ts`. -- **The live preview substitutes a shipped command at its own registry index.** `buildKeyMap` is - first-writer-wins, so appending the draft instead would preview a different resolution than the - save produces. See `previewCommands` in `src/options/model/form.ts`. -- **Format 1 exports can carry a `media` category.** Refusing them made every such file - unimportable, with hand-editing JSON as the only fix. An unknown category on a user shortcut - falls back to My shortcuts. An unknown category on an edit is dropped instead, because a shipped - command has its own. The two are deliberately not symmetric. See invariant 17. -- **A re-minted custom id has to be rewritten in `disabled` and `deleted` too.** Otherwise the - entries follow the wrong shortcut and the newcomer inherits the incumbent's history. See - `landedAs` in `src/lib/merge-import.ts`. -- **`.panel-head-text` does not exist in the design bundle.** The product's panel heads carry a - "Saved" announcement beside the title and no artboard shows it. It is gap 4 in - `.context/design-feedback.md`. -- **`?raw` CSS imports need `css: true` in `vitest.config.ts`.** Vitest stubs anything matching - `*.css` to an empty module, and that stub beats the raw loader. Without the flag the sheets - arrive as empty strings and every token assertion passes vacuously. -- **`light-dark()` needs Chrome 123.** That is why `public/manifest.json` sets - `minimum_chrome_version` to `123`, and `tests/tokens.test.ts` pins it. -- **`--accent` and `--accent-fg` must stay flat hexes.** `scripts/gen-icons.mjs` parses those exact - declarations to colour the icon. Wrapping either in `light-dark()` throws the build. -- **`.spec-row` is a harness class.** It belongs to `design/preview.css` and appears in the - artboards. The product renders `.row`. A harness class must never reach the shipped sheet. -- **`hasOnboarded` is true on every real install** by the time the welcome tab opens, because the - starter pick is written first. It comes apart from "a pick is live" for a format 1 profile - arriving from Settings, or an install whose write failed: those have every shipped shortcut on - and no pick on record, so `initialPicks` in `src/options/model/welcome.ts` opens the starter set - ticked rather than an empty screen. - -## 7. How to run things - -```bash -pnpm install # pnpm only. npm install creates a second lockfile. -pnpm typecheck && pnpm test && pnpm build # the gate -pnpm package # build, then release/bunnylol-.zip -node scripts/gen-icons.mjs # repaint the icons from design/tokens.css -``` - -`pnpm build` writes `dist/`. Load that folder unpacked at `chrome://extensions` with Developer -mode on. Other Chromium browsers work the same way. - -**Reload, do not remove and re-add.** Editing source does not update a loaded extension, so click -reload on the card after every build. Removing the extension and adding it back is a different -thing: it usually takes the profile's storage with it, and the install path then rewrites the -starter pick and opens the welcome tab. If the storage does survive, `writeStarterPick` is guarded -by `hasOnboarded` and does nothing. Either way, use **Settings, Data, Start over** when you want to -see the first run again on purpose. It deletes the state key and reruns the install path. - -The design previews are plain HTML files. Open them in a browser straight from disk: - -``` -design/foundations/ colours, type, space and radius -design/components/ buttons, inputs, status, messages -design/patterns/ topbar-nav, browse, edit-form, settings-sections, welcome, popup, dispatch -design/canvas/ the approved artboards, *.dc.html -``` - -One caveat. `design/preview.css` loads the font as `../fonts/InterVariable.woff2`, which resolves -relative to the stylesheet and so points outside `design/`. The standalone previews therefore fall -back to the system font. The fix is gap 1 in `.context/design-feedback.md`. It affects the review -harness only. The shipped pages load Inter from `public/fonts/`. - -## 8. Where to look next - -| If you want to | Start here | -|---|---| -| Change the palette, type scale or spacing | `design/tokens.css`, then `pnpm build` to repaint the icons and commit the PNGs. `tests/tokens.test.ts` is the guard. | -| Restyle a surface | The contract in `design/components.css` and the matching file in `design/patterns/`. Then `src/options/options.css` or `src/popup/popup.css`. | -| Add or change a shortcut | `src/lib/commands.ts`, plus the registry rules in `AGENTS.md` under "Editing the command registry". Aliases are globally unique and every handler must be used. | -| Add a smart argument handler | `src/lib/handlers.ts`, and the `HandlerId` union in `src/lib/types.ts`. Guard the input shape and degrade to a search (invariant 7). | -| Change onboarding | `src/lib/onboarding.ts` for what a pick means, `src/lib/install.ts` for the install path, `src/options/model/welcome.ts` for what the page says, `src/options/views/welcome.ts` for the DOM. | -| Change the import or export format | The `normalize*` and `parse*` pairs in `src/lib/storage.ts`, then `src/lib/merge-import.ts`. Every new field needs both halves of the pair. | -| Change validation | `src/lib/validate.ts`, and only there. Add a call site rather than a local rule (invariant 6). | -| Touch address-bar interception | `src/lib/dnr.ts` and `src/lib/resolve.ts`. Read invariants 1 to 5 and 15 first, and replay real Chrome URLs through `tests/helpers/rules.ts`. | -| Change the browse list, filter or folds | `src/options/model/browse.ts` and `model/collapse.ts` for the decisions, `views/browse.ts` for the DOM. `applyFilter` is the only writer of row and rows `hidden`, and of both counts. | -| Change the edit form | `src/lib/draft.ts` for parsing, `src/options/model/form.ts` for validation and the preview, `src/options/views/form.ts` for the DOM. | -| Change what a section is | `src/lib/overrides.ts`. Read invariant 17: every lookup keyed by a category is hostile input. | -| Change the packaging or the manifest | `public/manifest.json`, `scripts/package.mjs`, `tests/manifest.test.ts`. Bump both versions in one commit. | -| Submit to the Web Store | `docs/chrome-web-store.md`. It has the permission justifications, the privacy answers and the upload checklist. | -| Add a shortcut pack | `extras/packs/`. Data, not code, and outside tsconfig on purpose. `extras/packs/README.md` documents the format. | diff --git a/docs/images/editor.png b/docs/images/editor.png new file mode 100644 index 0000000..81d63ad Binary files /dev/null and b/docs/images/editor.png differ diff --git a/docs/images/popup.png b/docs/images/popup.png new file mode 100644 index 0000000..c1cbfee Binary files /dev/null and b/docs/images/popup.png differ diff --git a/docs/images/shortcuts.png b/docs/images/shortcuts.png new file mode 100644 index 0000000..b70c6e8 Binary files /dev/null and b/docs/images/shortcuts.png differ diff --git a/docs/images/welcome.png b/docs/images/welcome.png new file mode 100644 index 0000000..7234781 Binary files /dev/null and b/docs/images/welcome.png differ diff --git a/eslint.config.js b/eslint.config.js new file mode 100644 index 0000000..7671c67 --- /dev/null +++ b/eslint.config.js @@ -0,0 +1,163 @@ +// Flat config. The rules worth having are the ones `tsc` cannot see and that +// CONTRIBUTING.md states by hand. Anything the typechecker already fails on is +// not repeated here: `pnpm typecheck` is the gate that catches it. +// +// Every `off` below is a convention this repo made on purpose, with the reason +// written next to it. None of them was silenced to avoid editing code. +import js from '@eslint/js'; +import tseslint from 'typescript-eslint'; + +export default tseslint.config( + { + // Build output, the design bundle (reviewed, not linted) and `extras/`, + // which is data and deliberately outside tsconfig, so no program covers it. + ignores: ['dist/', 'release/', 'design/', 'extras/', 'node_modules/'], + }, + + js.configs.recommended, + tseslint.configs.recommendedTypeChecked, + + { + files: ['**/*.ts', '**/*.mts'], + languageOptions: { + parserOptions: { + // Reads tsconfig.json, and covers the handful of files outside its + // `include` without a second tsconfig to keep in step with the first. + projectService: { allowDefaultProject: ['vitest.config.ts'] }, + tsconfigRootDir: import.meta.dirname, + }, + }, + rules: { + // CONTRIBUTING.md, "Style": no default exports. A named export is + // greppable and cannot be silently renamed at the import site. Written as + // a core selector rather than pulling eslint-plugin-import-x, whose only + // other rule worth having here (`order`) is turned down below anyway and + // which drags in a native postinstall binary for module resolution. + 'no-restricted-syntax': [ + 'error', + { + selector: 'ExportDefaultDeclaration', + message: 'No default exports. Export a named binding instead.', + }, + ], + + // `verbatimModuleSyntax` is on, so a type imported without `type` is + // emitted as a real import. tsc does not require the keyword; this does. + // `disallowTypeAnnotations` is off: an inline `import('…')` in a type + // position emits nothing, so the rule's reason does not reach it. + '@typescript-eslint/consistent-type-imports': [ + 'error', + { + prefer: 'type-imports', + fixStyle: 'separate-type-imports', + disallowTypeAnnotations: false, + }, + ], + '@typescript-eslint/no-import-type-side-effects': 'error', + + // Matches what `noUnusedParameters` already does: a leading underscore is + // how this repo says "required by the signature, unused on purpose", and + // `src/lib/handlers.ts` is full of `_settings`. + '@typescript-eslint/no-unused-vars': [ + 'error', + { + argsIgnorePattern: '^_', + varsIgnorePattern: '^_', + caughtErrorsIgnorePattern: '^_', + }, + ], + + // `let x; … x = …` where the closure above x reads it. `const` is not + // available there, so the default reading of "never reassigned" is wrong. + 'prefer-const': ['error', { ignoreReadBeforeAssign: true }], + }, + }, + + { + // ---- Rules turned off, and why ---- + files: ['**/*.ts', '**/*.mts'], + rules: { + // Every hit is `Object.assign(Object.create(null), …)`, which is this + // repo's prototype-pollution defence: a shortcut id is a key off + // untrusted JSON, so the maps holding them are null-prototype on purpose + // (AGENTS.md invariant 17). `Object.create` is typed `any`, so the rule + // fires on exactly the code that exists to be safe. + '@typescript-eslint/no-unsafe-assignment': 'off', + + // `@types/chrome` models `details.reason` as an enum, and comparing it to + // `'install'` is the documented Chrome idiom and what every call site + // here does. The enum members are those strings. + '@typescript-eslint/no-unsafe-enum-comparison': 'off', + + // The three hits are assertions at a trust boundary that narrow input the + // typechecker happens to have already narrowed. They document what the + // code assumes about a `?raw` blob or a `sendMessage` reply; deleting + // them would make the module depend silently on inference. + '@typescript-eslint/no-unnecessary-type-assertion': 'off', + }, + }, + + { + // Vite and Vitest load their config through a default export. There is no + // named form, so the rule is off here rather than the files being changed. + files: ['vite.config.ts', 'vitest.config.ts'], + rules: { 'no-restricted-syntax': 'off' }, + }, + + { + files: ['tests/**/*.ts'], + rules: { + // The suites hand hostile, deliberately untyped blobs to the storage and + // import boundaries: that is what invariants 16 and 17 are tested with. + // A fixture that had to typecheck could not express the shapes the parser + // exists to refuse. + '@typescript-eslint/no-unsafe-member-access': 'off', + '@typescript-eslint/no-unsafe-argument': 'off', + '@typescript-eslint/no-unsafe-call': 'off', + + // `tests/helpers/rules.ts` stubs promise-returning chrome APIs. The stubs + // must be `async` to match the signature they replace, and none of them + // has anything to await. + '@typescript-eslint/require-await': 'off', + + // `declare const globalThis: { chrome?: unknown }` in tests/url.test.ts + // is a type declaration, not a binding that shadows anything at runtime. + 'no-shadow-restricted-names': 'off', + }, + }, + + { + // ---- Left on, and currently warning ---- + // `preserve-caught-error` wants `{ cause: err }` on the error thrown from + // the JSON catch in src/lib/storage/parse-import.ts. That is a fair + // suggestion rather than a convention to overrule, so it stays visible as a + // warning instead of being switched off. Nothing reads `.cause` today and + // the message already interpolates the underlying text, so the fix is + // somebody's call, not this config's. + files: ['**/*.ts', '**/*.mts'], + rules: { 'preserve-caught-error': 'warn' }, + }, + + { + // This file. It default-exports because that is how flat config is loaded, + // and no TypeScript program covers it. + files: ['eslint.config.js'], + extends: [tseslint.configs.disableTypeChecked], + rules: { 'no-restricted-syntax': 'off' }, + }, + + { + // Plain Node scripts. tsconfig has `allowJs` off, so no program covers + // them and the type-aware rules have nothing to read. + files: ['scripts/**/*.mjs'], + extends: [tseslint.configs.disableTypeChecked], + languageOptions: { + globals: { + Buffer: 'readonly', + URL: 'readonly', + console: 'readonly', + process: 'readonly', + }, + }, + }, +); diff --git a/extras/packs/removed-commands.json b/extras/packs/removed-commands.json index f1faac7..23a8692 100644 --- a/extras/packs/removed-commands.json +++ b/extras/packs/removed-commands.json @@ -9,10 +9,7 @@ ], "custom": [ { - "keys": [ - "copilot", - "msai" - ], + "keys": ["copilot", "msai"], "name": "Microsoft Copilot", "description": "Open Microsoft Copilot; arguments search the web.", "url": "https://copilot.microsoft.com/", @@ -22,10 +19,7 @@ "_note": "Consumer Copilot has no supported search route. Every `?q=` form returns a 302 to the bare home page and drops the prompt, including Microsoft's own bing.com/search?showconv=1 entry point. So this opens the app rather than a guessed URL. The app is a chat SPA with nothing indexed, so a site: search would be just as empty. The words go to a plain search instead." }, { - "keys": [ - "hf", - "huggingface" - ], + "keys": ["hf", "huggingface"], "name": "Hugging Face", "description": "Search models, datasets and spaces.", "url": "https://huggingface.co/", @@ -34,10 +28,7 @@ "example": "hf whisper large" }, { - "keys": [ - "bing", - "b" - ], + "keys": ["bing", "b"], "name": "Bing", "description": "Search Bing.", "url": "https://www.bing.com/", @@ -46,9 +37,7 @@ "example": "bing weather chicago" }, { - "keys": [ - "brave" - ], + "keys": ["brave"], "name": "Brave Search", "description": "Search with Brave.", "url": "https://search.brave.com/", @@ -57,9 +46,7 @@ "example": "brave rust vs zig" }, { - "keys": [ - "kagi" - ], + "keys": ["kagi"], "name": "Kagi", "description": "Search with Kagi.", "url": "https://kagi.com/", @@ -68,10 +55,7 @@ "example": "kagi sqlite wal mode" }, { - "keys": [ - "gsite", - "site" - ], + "keys": ["gsite", "site"], "name": "Site search", "description": "Google a single site. First word is the domain.", "url": "https://www.google.com/", @@ -80,10 +64,7 @@ "example": "gsite react.dev hooks -> google site:react.dev hooks" }, { - "keys": [ - "weather", - "wx" - ], + "keys": ["weather", "wx"], "name": "Weather", "description": "Weather for a place.", "url": "https://www.google.com/search?q=weather", @@ -92,10 +73,7 @@ "example": "weather west lafayette" }, { - "keys": [ - "stock", - "ticker" - ], + "keys": ["stock", "ticker"], "name": "Yahoo Finance", "description": "Quote for a ticker symbol.", "url": "https://finance.yahoo.com/", @@ -105,10 +83,7 @@ "example": "stock NVDA" }, { - "keys": [ - "wayback", - "archive" - ], + "keys": ["wayback", "archive"], "name": "Wayback Machine", "description": "Latest archived snapshot of a URL.", "url": "https://web.archive.org/", @@ -118,10 +93,7 @@ "example": "wayback nytimes.com" }, { - "keys": [ - "lh", - "localhost" - ], + "keys": ["lh", "localhost"], "name": "localhost", "description": "Open a local dev server. Bare invocation uses port 3000.", "url": "http://localhost:3000", @@ -130,10 +102,7 @@ "example": "lh 8080 -> http://localhost:8080" }, { - "keys": [ - "so", - "stackoverflow" - ], + "keys": ["so", "stackoverflow"], "name": "Stack Overflow", "description": "Search Stack Overflow.", "url": "https://stackoverflow.com/", @@ -142,9 +111,7 @@ "example": "so python asyncio gather exception" }, { - "keys": [ - "mdn" - ], + "keys": ["mdn"], "name": "MDN Web Docs", "description": "Web platform documentation.", "url": "https://developer.mozilla.org/", @@ -153,10 +120,7 @@ "example": "mdn structuredClone" }, { - "keys": [ - "caniuse", - "ciu" - ], + "keys": ["caniuse", "ciu"], "name": "Can I use", "description": "Browser support tables.", "url": "https://caniuse.com/", @@ -165,10 +129,7 @@ "example": "caniuse container queries" }, { - "keys": [ - "lc", - "leetcode" - ], + "keys": ["lc", "leetcode"], "name": "LeetCode", "description": "Search LeetCode problems.", "url": "https://leetcode.com/problemset/", @@ -177,9 +138,7 @@ "example": "lc two sum" }, { - "keys": [ - "pypi" - ], + "keys": ["pypi"], "name": "PyPI", "description": "Search Python packages.", "url": "https://pypi.org/", @@ -188,10 +147,7 @@ "example": "pypi httpx" }, { - "keys": [ - "pydocs", - "pydoc" - ], + "keys": ["pydocs", "pydoc"], "name": "Python Docs", "description": "Search the Python 3 standard library docs.", "url": "https://docs.python.org/3/", @@ -200,9 +156,7 @@ "example": "pydocs itertools groupby" }, { - "keys": [ - "crates" - ], + "keys": ["crates"], "name": "crates.io", "description": "Search Rust crates.", "url": "https://crates.io/", @@ -211,9 +165,7 @@ "example": "crates serde" }, { - "keys": [ - "docsrs" - ], + "keys": ["docsrs"], "name": "docs.rs", "description": "Rust crate API documentation.", "url": "https://docs.rs/", @@ -222,10 +174,7 @@ "example": "docsrs tokio" }, { - "keys": [ - "golang", - "gopkg" - ], + "keys": ["golang", "gopkg"], "name": "pkg.go.dev", "description": "Search Go packages.", "url": "https://pkg.go.dev/", @@ -234,9 +183,7 @@ "example": "golang errgroup" }, { - "keys": [ - "rubygems" - ], + "keys": ["rubygems"], "name": "RubyGems", "description": "Search Ruby gems.", "url": "https://rubygems.org/", @@ -245,10 +192,7 @@ "example": "rubygems rails" }, { - "keys": [ - "packagist", - "composer" - ], + "keys": ["packagist", "composer"], "name": "Packagist", "description": "Search PHP packages.", "url": "https://packagist.org/", @@ -257,9 +201,7 @@ "example": "packagist guzzle" }, { - "keys": [ - "nuget" - ], + "keys": ["nuget"], "name": "NuGet", "description": "Search .NET packages.", "url": "https://www.nuget.org/", @@ -268,10 +210,7 @@ "example": "nuget newtonsoft.json" }, { - "keys": [ - "mvn", - "maven" - ], + "keys": ["mvn", "maven"], "name": "Maven Central", "description": "Search Java artifacts.", "url": "https://mvnrepository.com/", @@ -280,10 +219,7 @@ "example": "mvn jackson databind" }, { - "keys": [ - "dockerhub", - "docker" - ], + "keys": ["dockerhub", "docker"], "name": "Docker Hub", "description": "Search container images.", "url": "https://hub.docker.com/", @@ -292,10 +228,7 @@ "example": "docker postgres" }, { - "keys": [ - "gitlab", - "gl" - ], + "keys": ["gitlab", "gl"], "name": "GitLab", "description": "Search GitLab projects.", "url": "https://gitlab.com/", @@ -305,10 +238,7 @@ "_note": "gitlab.com/search requires auth and bounces signed-out users to sign-in; /explore/projects is the public equivalent." }, { - "keys": [ - "bitbucket", - "bb" - ], + "keys": ["bitbucket", "bb"], "name": "Bitbucket", "description": "Bitbucket workspaces and repos.", "url": "https://bitbucket.org/", @@ -317,10 +247,7 @@ "example": "bitbucket -> bitbucket.org" }, { - "keys": [ - "sourcegraph", - "sg" - ], + "keys": ["sourcegraph", "sg"], "name": "Sourcegraph", "description": "Cross-repository code search.", "url": "https://sourcegraph.com/", @@ -329,10 +256,7 @@ "example": "sg lang:go http.HandlerFunc" }, { - "keys": [ - "devdocs", - "dd" - ], + "keys": ["devdocs", "dd"], "name": "DevDocs", "description": "Unified API documentation browser.", "url": "https://devdocs.io/", @@ -341,10 +265,7 @@ "example": "dd array.prototype.flatmap" }, { - "keys": [ - "ts", - "typescript" - ], + "keys": ["ts", "typescript"], "name": "TypeScript", "description": "TypeScript handbook and docs.", "url": "https://www.typescriptlang.org/", @@ -353,9 +274,7 @@ "example": "ts satisfies operator" }, { - "keys": [ - "react" - ], + "keys": ["react"], "name": "React", "description": "React documentation.", "url": "https://react.dev/", @@ -364,10 +283,7 @@ "example": "react useSyncExternalStore" }, { - "keys": [ - "node", - "nodedocs" - ], + "keys": ["node", "nodedocs"], "name": "Node.js Docs", "description": "Node.js API documentation.", "url": "https://nodejs.org/api/", @@ -376,10 +292,7 @@ "example": "node fs promises readFile" }, { - "keys": [ - "bundlephobia", - "bphobia" - ], + "keys": ["bundlephobia", "bphobia"], "name": "Bundlephobia", "description": "Size cost of an npm package.", "url": "https://bundlephobia.com/", @@ -389,9 +302,7 @@ "example": "bundlephobia lodash" }, { - "keys": [ - "npmtrends" - ], + "keys": ["npmtrends"], "name": "npm trends", "description": "Compare npm package downloads.", "url": "https://npmtrends.com/", @@ -402,9 +313,7 @@ "_note": "Package pages intermittently 500 upstream (e.g. /react) while others load; the URL shape is correct, so the command stays as-is." }, { - "keys": [ - "codepen" - ], + "keys": ["codepen"], "name": "CodePen", "description": "Search pens.", "url": "https://codepen.io/", @@ -413,10 +322,7 @@ "example": "codepen css grid gallery" }, { - "keys": [ - "vscode", - "vsx" - ], + "keys": ["vscode", "vsx"], "name": "VS Code", "description": "VS Code site; arguments search the extension marketplace.", "url": "https://code.visualstudio.com/", @@ -425,10 +331,7 @@ "example": "vsx eslint" }, { - "keys": [ - "gnews", - "news" - ], + "keys": ["gnews", "news"], "name": "Google News", "description": "Search the news.", "url": "https://news.google.com/", @@ -437,11 +340,7 @@ "example": "news semiconductor tariffs" }, { - "keys": [ - "gimg", - "img", - "images" - ], + "keys": ["gimg", "img", "images"], "name": "Google Images", "description": "Google image search.", "url": "https://images.google.com/", @@ -450,10 +349,7 @@ "example": "img purdue bell tower" }, { - "keys": [ - "gvid", - "videos" - ], + "keys": ["gvid", "videos"], "name": "Google Video", "description": "Google video search.", "url": "https://www.google.com/search?tbm=vid", @@ -462,10 +358,7 @@ "example": "gvid rust lifetimes talk" }, { - "keys": [ - "gbooks", - "books" - ], + "keys": ["gbooks", "books"], "name": "Google Books", "description": "Search books.", "url": "https://books.google.com/", @@ -474,10 +367,7 @@ "example": "gbooks godel escher bach" }, { - "keys": [ - "gflights", - "flights" - ], + "keys": ["gflights", "flights"], "name": "Google Flights", "description": "Search flights.", "url": "https://www.google.com/travel/flights", @@ -486,10 +376,7 @@ "example": "gflights ind to sfo friday" }, { - "keys": [ - "gtrends", - "trends" - ], + "keys": ["gtrends", "trends"], "name": "Google Trends", "description": "Explore search interest over time.", "url": "https://trends.google.com/trends/", @@ -498,10 +385,7 @@ "example": "gtrends electric vehicles" }, { - "keys": [ - "gplay", - "play" - ], + "keys": ["gplay", "play"], "name": "Google Play", "description": "Search the Play Store.", "url": "https://play.google.com/store", @@ -510,10 +394,7 @@ "example": "gplay duolingo" }, { - "keys": [ - "sharepoint", - "sp" - ], + "keys": ["sharepoint", "sp"], "name": "SharePoint", "description": "SharePoint start page, or search your sites.", "url": "https://m365.cloud.microsoft/launch/sharepoint", @@ -522,10 +403,7 @@ "example": "sharepoint team site" }, { - "keys": [ - "azure", - "az" - ], + "keys": ["azure", "az"], "name": "Azure Portal", "description": "Azure portal; arguments search Microsoft Learn.", "url": "https://portal.azure.com/", @@ -534,10 +412,7 @@ "example": "az blob storage lifecycle" }, { - "keys": [ - "mslearn", - "learn" - ], + "keys": ["mslearn", "learn"], "name": "Microsoft Learn", "description": "Microsoft technical documentation.", "url": "https://learn.microsoft.com/", @@ -546,10 +421,7 @@ "example": "mslearn graph api permissions" }, { - "keys": [ - "gss", - "gradescopesso" - ], + "keys": ["gss", "gradescopesso"], "name": "Gradescope (Purdue login)", "description": "Sign in to Gradescope with Purdue school credentials.", "url": "https://www.gradescope.com/login", @@ -559,10 +431,7 @@ "_note": "Gradescope's own login page; \"School Credentials\" is the Purdue SAML SSO path. Purdue's former idp/gradescope1 entry point no longer exists." }, { - "keys": [ - "purdue", - "pu" - ], + "keys": ["purdue", "pu"], "name": "Purdue University", "description": "purdue.edu, or search the Purdue site.", "url": "https://www.purdue.edu/", @@ -571,10 +440,7 @@ "example": "purdue academic integrity policy" }, { - "keys": [ - "boilerconnect", - "bcon" - ], + "keys": ["boilerconnect", "bcon"], "name": "BoilerConnect", "description": "Advising appointments and student success.", "url": "https://purdue.campus.eab.com/", @@ -583,10 +449,7 @@ "example": "boilerconnect -> advising appointments" }, { - "keys": [ - "courseinsights", - "ci" - ], + "keys": ["courseinsights", "ci"], "name": "Purdue Course Insights", "description": "Course grade distributions; arguments search the web.", "url": "https://sswis.mypurdue.purdue.edu/CourseInsights/", @@ -596,11 +459,7 @@ "_note": "Login-walled SPA: a site: fallback would send arguments nowhere useful, so they go to a plain search. A bare invocation opens the app." }, { - "keys": [ - "catalog", - "pcat", - "courses" - ], + "keys": ["catalog", "pcat", "courses"], "name": "Purdue Course Catalog", "description": "Official course descriptions and requirements.", "url": "https://catalog.purdue.edu/", @@ -609,10 +468,7 @@ "example": "catalog ma 26100" }, { - "keys": [ - "citybus", - "bus" - ], + "keys": ["citybus", "bus"], "name": "CityBus", "description": "Greater Lafayette CityBus routes and tracker.", "url": "https://www.in.gov/citybuslafayette/", @@ -621,10 +477,7 @@ "example": "citybus route 4" }, { - "keys": [ - "purduesports", - "boilers" - ], + "keys": ["purduesports", "boilers"], "name": "Purdue Athletics", "description": "Schedules, scores and tickets.", "url": "https://purduesports.com/", @@ -633,9 +486,7 @@ "example": "boilers basketball schedule" }, { - "keys": [ - "piazza" - ], + "keys": ["piazza"], "name": "Piazza", "description": "Course Q&A boards.", "url": "https://piazza.com/", @@ -644,9 +495,7 @@ "example": "piazza -> piazza.com" }, { - "keys": [ - "quora" - ], + "keys": ["quora"], "name": "Quora", "description": "Search questions and answers.", "url": "https://www.quora.com/", @@ -655,10 +504,7 @@ "example": "quora how do jet engines work" }, { - "keys": [ - "slack", - "slk" - ], + "keys": ["slack", "slk"], "name": "Slack", "description": "Open Slack in the browser.", "url": "https://app.slack.com/client", @@ -667,10 +513,7 @@ "example": "slack -> app.slack.com" }, { - "keys": [ - "spot", - "spotify" - ], + "keys": ["spot", "spotify"], "name": "Spotify", "description": "Search Spotify.", "url": "https://open.spotify.com/", @@ -679,10 +522,7 @@ "example": "spot bonobo" }, { - "keys": [ - "ytm", - "ytmusic" - ], + "keys": ["ytm", "ytmusic"], "name": "YouTube Music", "description": "Search YouTube Music.", "url": "https://music.youtube.com/", @@ -691,10 +531,7 @@ "example": "ytm radiohead" }, { - "keys": [ - "sc", - "soundcloud" - ], + "keys": ["sc", "soundcloud"], "name": "SoundCloud", "description": "Search SoundCloud.", "url": "https://soundcloud.com/", @@ -703,9 +540,7 @@ "example": "sc dj sets" }, { - "keys": [ - "bandcamp" - ], + "keys": ["bandcamp"], "name": "Bandcamp", "description": "Search artists and albums.", "url": "https://bandcamp.com/", @@ -714,9 +549,7 @@ "example": "bandcamp khruangbin" }, { - "keys": [ - "genius" - ], + "keys": ["genius"], "name": "Genius", "description": "Search song lyrics.", "url": "https://genius.com/", @@ -725,10 +558,7 @@ "example": "genius pyramids" }, { - "keys": [ - "nf", - "netflix" - ], + "keys": ["nf", "netflix"], "name": "Netflix", "description": "Search Netflix.", "url": "https://www.netflix.com/browse", @@ -737,9 +567,7 @@ "example": "nf arcane" }, { - "keys": [ - "hulu" - ], + "keys": ["hulu"], "name": "Hulu", "description": "Search Hulu.", "url": "https://www.hulu.com/hub/home", @@ -748,10 +576,7 @@ "example": "hulu the bear" }, { - "keys": [ - "primevideo", - "pv" - ], + "keys": ["primevideo", "pv"], "name": "Prime Video", "description": "Open Prime Video.", "url": "https://www.primevideo.com/", @@ -760,10 +585,7 @@ "example": "primevideo -> primevideo.com" }, { - "keys": [ - "disney", - "dplus" - ], + "keys": ["disney", "dplus"], "name": "Disney+", "description": "Open Disney+.", "url": "https://www.disneyplus.com/", @@ -772,9 +594,7 @@ "example": "disney -> disneyplus.com" }, { - "keys": [ - "twitch" - ], + "keys": ["twitch"], "name": "Twitch", "description": "Search Twitch channels and categories.", "url": "https://www.twitch.tv/", @@ -783,9 +603,7 @@ "example": "twitch speedrun" }, { - "keys": [ - "imdb" - ], + "keys": ["imdb"], "name": "IMDb", "description": "Search films, shows and people.", "url": "https://www.imdb.com/", @@ -794,10 +612,7 @@ "example": "imdb dune part two" }, { - "keys": [ - "rt", - "rottentomatoes" - ], + "keys": ["rt", "rottentomatoes"], "name": "Rotten Tomatoes", "description": "Search reviews and scores.", "url": "https://www.rottentomatoes.com/", @@ -806,10 +621,7 @@ "example": "rt the batman" }, { - "keys": [ - "lb", - "letterboxd" - ], + "keys": ["lb", "letterboxd"], "name": "Letterboxd", "description": "Search films on Letterboxd.", "url": "https://letterboxd.com/", @@ -818,10 +630,7 @@ "example": "lb parasite" }, { - "keys": [ - "mal", - "anime" - ], + "keys": ["mal", "anime"], "name": "MyAnimeList", "description": "Search anime and manga.", "url": "https://myanimelist.net/", @@ -830,9 +639,7 @@ "example": "mal frieren" }, { - "keys": [ - "steam" - ], + "keys": ["steam"], "name": "Steam", "description": "Search the Steam store.", "url": "https://store.steampowered.com/", @@ -841,9 +648,7 @@ "example": "steam factorio" }, { - "keys": [ - "vimeo" - ], + "keys": ["vimeo"], "name": "Vimeo", "description": "Search Vimeo.", "url": "https://vimeo.com/", @@ -852,10 +657,7 @@ "example": "vimeo short film" }, { - "keys": [ - "tg", - "telegram" - ], + "keys": ["tg", "telegram"], "name": "Telegram", "description": "Telegram Web, or open a @username.", "url": "https://web.telegram.org/", @@ -865,11 +667,7 @@ "example": "tg durov" }, { - "keys": [ - "gtrans", - "translate", - "tr" - ], + "keys": ["gtrans", "translate", "tr"], "name": "Google Translate", "description": "Translate text into English (auto-detect source).", "url": "https://translate.google.com/", @@ -878,10 +676,7 @@ "example": "tr wo ist der bahnhof" }, { - "keys": [ - "gkeep", - "keep" - ], + "keys": ["gkeep", "keep"], "name": "Google Keep", "description": "Open Keep or search your notes.", "url": "https://keep.google.com/", @@ -890,10 +685,7 @@ "example": "gkeep groceries" }, { - "keys": [ - "gphotos", - "photos" - ], + "keys": ["gphotos", "photos"], "name": "Google Photos", "description": "Open Photos or search your library.", "url": "https://photos.google.com/", @@ -902,11 +694,7 @@ "example": "gphotos graduation" }, { - "keys": [ - "gscholar", - "sch", - "scholar" - ], + "keys": ["gscholar", "sch", "scholar"], "name": "Google Scholar", "description": "Search academic papers.", "url": "https://scholar.google.com/", @@ -915,10 +703,7 @@ "example": "sch attention is all you need" }, { - "keys": [ - "gcontacts", - "contacts" - ], + "keys": ["gcontacts", "contacts"], "name": "Google Contacts", "description": "Open Contacts or search people.", "url": "https://contacts.google.com/", diff --git a/go.html b/go.html index f88bda6..83bfaaa 100644 --- a/go.html +++ b/go.html @@ -4,9 +4,8 @@ BunnyLol -