Skip to content
Merged
Show file tree
Hide file tree
Changes from 18 commits
Commits
Show all changes
22 commits
Select commit Hold shift + click to select a range
46eccf6
Fix two shipped examples that told people to type the wrong keyword
ion05 Sep 3, 2026
b230ea4
Rewrite the README as the front door of a public repository
ion05 Sep 3, 2026
42451f5
Null-prototype every lookup table an argument can index
ion05 Sep 3, 2026
8c0b89f
Make the privacy policy exhaustive about where data is kept
ion05 Sep 3, 2026
cd432b4
Drop the internal handover doc and keep the facts worth keeping
ion05 Sep 3, 2026
6884d7e
Correct the changelog where it contradicted itself and the code
ion05 Sep 3, 2026
f48e2cc
Disclaim the Meta affiliation on the screen that claims it
ion05 Sep 3, 2026
a868dec
Finish the repository for a first-time visitor
ion05 Sep 3, 2026
6cf1fff
Fix three ways the Shortcuts page lost the user's place or its state
ion05 Sep 3, 2026
074c8a5
Route the exempt-keyword field through the one validation boundary
ion05 Sep 3, 2026
0460918
Split storage.ts into the two families it always had
ion05 Sep 4, 2026
c38fc76
Split dnr.ts along its three concerns
ion05 Sep 4, 2026
0f1056b
Split the browse route so its one rule can be checked by grep
ion05 Sep 4, 2026
6bc73cc
Give the browse view a DOM test suite, on jsdom
ion05 Sep 4, 2026
7ccb702
Delete 39 test cases that were covering something else's ground
ion05 Sep 4, 2026
3790bbb
Add Prettier and ESLint, and put lint first in the gate
ion05 Sep 4, 2026
3c89e3b
Sweep the dead exports and the comments that describe old approaches
ion05 Sep 4, 2026
d259c3a
Turn on noUncheckedIndexedAccess and answer what it asks
ion05 Sep 4, 2026
4e25a7b
Cut the test suite to the 150 cases that would catch a broken build
ion05 Sep 4, 2026
1ef2032
docs: refresh README and add UI screenshots
ion05 Sep 4, 2026
e673cc0
Remove the Web Store submission material
ion05 Sep 4, 2026
0217322
Drop the lead-in sentence above the README examples
ion05 Sep 4, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions .gitattributes
Original file line number Diff line number Diff line change
@@ -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
7 changes: 4 additions & 3 deletions .github/ISSUE_TEMPLATE/bug_report.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
18 changes: 18 additions & 0 deletions .github/dependabot.yml
Original file line number Diff line number Diff line change
@@ -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:'
7 changes: 6 additions & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down
27 changes: 27 additions & 0 deletions .prettierignore
Original file line number Diff line number Diff line change
@@ -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 and asserted on by tests/tokens.test.ts. 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. tests/tokens.test.ts also pins it as text, matching `.err-title{` with
# no space before the brace, which is exactly what a CSS formatter would insert.
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
10 changes: 10 additions & 0 deletions .prettierrc.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
{
"printWidth": 100,
"tabWidth": 2,
"useTabs": false,
"semi": true,
"singleQuote": true,
"trailingComma": "all",
"arrowParens": "always",
"endOfLine": "lf"
}
100 changes: 80 additions & 20 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -75,9 +83,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
Expand Down Expand Up @@ -139,13 +160,14 @@ tests. **If a test in this list fails, do not "fix" the test.**
aliases: at ~400 custom shortcuts, `gh`, `g` and `npm` silently stopped being intercepted.

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
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.
Expand Down Expand Up @@ -227,8 +249,8 @@ tests. **If a test in this list fails, do not "fix" the test.**
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
Expand Down Expand Up @@ -309,21 +331,46 @@ 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`.
- **`?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, so without the flag the sheets
arrive as empty strings and every token assertion passes vacuously.
- **`--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

The most valuable bugs here were found by *running* code, not inspecting it. The DNR regex looked
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
`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.

## 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/
Expand All @@ -343,8 +390,16 @@ 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 jsdom, 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
Expand All @@ -353,7 +408,12 @@ gitignored.
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. One suite opts out:
`tests/options-browse-dom.test.ts` carries `// @vitest-environment jsdom` in its own docblock,
because the Hidden shortcuts state machine moves DOM nodes without a re-render. The GLOBAL default
stays `node`, which is what keeps the rule above able to fail. jsdom does no layout, so that suite
cannot see `focus()` failing inside a `display: none` subtree and cannot see the CSS `order`
reordering at all. Both still need a real browser.
- 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.

Expand Down Expand Up @@ -387,9 +447,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
Expand Down
Loading
Loading