diff --git a/.oxlintrc.json b/.oxlintrc.json
index 00985a4..668417e 100644
--- a/.oxlintrc.json
+++ b/.oxlintrc.json
@@ -22,6 +22,7 @@
"__sentMessages",
"__broadcast",
"__failRefresh",
+ "__revalidating",
"__tabOpen",
"__DEV_RELOAD_ORIGIN__"
]
diff --git a/README.md b/README.md
index ab0eba9..5970f47 100644
--- a/README.md
+++ b/README.md
@@ -6,133 +6,51 @@ about.
## Features
-- **There when you want it.** A new tab starts closed behind a launcher button
- and costs nothing until you open it. Once open it is a floating window that
- can be dragged, resized from any edge or corner, locked in place, or collapsed
- to its header.
-- **Or docked into the page.** One click drops the window into the empty gutter
- github.com leaves to the left of its content, hanging below the site header
- so it reads as part of the page rather than on top of it. On a viewport with
- no gutter to spare, the page body is moved across to make room instead of
- being covered, while the header goes on spanning the window. Collapse it to a
- rail down the edge when you want the room back — it keeps counting.
-- **Saved queries.** Any number of named GitHub search queries, switchable from
- the header menu, in the same advanced syntax github.com's own search uses —
- `AND`, `OR`, and parentheses included.
-- **Live status.** Open/closed/merged/draft state, queued-to-merge, CI check
- rollup, and review decision for every row, refreshed on a configurable
- interval. Right-click any row to refresh just that one on demand.
-- **Cached across tabs.** Results are stored in IndexedDB in the service worker
- and shared by every github.com tab, so opening tabs costs no API calls.
-- **Fast lists.** Cursor-paginated infinite scroll with windowed virtualisation,
- so only the visible rows are ever in the DOM.
-- **Opens in a new window.** Clicking a row pops the item out; ⌘/Ctrl-click opens
- a tab instead.
+There when you want it — a new tab starts closed and costs nothing until you open it.
-## Install
-
-**Prerequisites**
+Once open it is a floating window that can be dragged, resized from any edge or
+corner, locked in place, or collapsed to its header.
-- **Node.js 22.18+** (or 23.6+). The build and dev scripts are TypeScript files
- run directly by Node, so they need a version with type stripping enabled by
- default. Check with `node -v`.
-- **npm 10+** (ships with the Node versions above).
-- **Google Chrome** (or another Chromium browser) to load the extension. Chrome
- is also required for the browser test suite.
+#### Open state is per tab
-**Build it**
+Whether the panel is showing belongs to the tab, not the user, so it is the one
+piece of state that is not in `chrome.storage.local`. Sharing it would mean
+opening the panel once opened it in every tab from then on, which is the
+opposite of a new tab starting out of the way.
-```bash
-git clone https://github.com/tmelliottjr/github-sidecar.git
-cd github-sidecar
-npm install
-npm run build
-```
+Instead the service worker keeps a flag in `chrome.storage.session` against the
+tab's id. That gives each tab its own answer, survives reloads and navigation
+within a tab, is cleared when the browser closes, and — unlike `sessionStorage`
+— writes nothing to github.com's own storage. Tab ids get reused, so the flag is
+dropped on `tabs.onRemoved`; otherwise the next tab to take that id would
+inherit a panel nobody opened.
-`npm run build` writes the unpacked extension to `dist/`. Neither `dist/` nor
-`node_modules/` is committed, so a fresh clone must be built before it can be
-loaded.
+Out of the way is not the same as gone, though, so a tab that has not been
+asked still leaves something to ask with: floating, a launcher in the corner;
+docked, the rail, exactly where the panel would have been. Neither costs a
+request — a rail is not an open panel — but neither sends anyone to the
+browser's extensions menu to find a panel that is meant to be part of the page.
-Then load it in Chrome:
+Opening a panel for the first time does not mean going to the network. The
+request goes to the worker like any other, which answers from its shared
+IndexedDB cache and only refreshes if what it has has aged out.
-1. Visit `chrome://extensions` and enable **Developer mode**.
-2. Choose **Load unpacked** and select the `dist/` directory.
-3. The options page opens on first install — paste a GitHub personal access
- token and click **Verify**.
+Everything else about the window — size, position, docked, collapsed, locked —
+stays shared, because those are preferences rather than per-tab facts.
-A classic token needs `repo` and `read:org` scope to see private repositories.
-The token is stored in `chrome.storage.local` and is only ever sent to
-`api.github.com`.
+Or docked into the page — one click drops it into the gutter github.com leaves empty, so nothing is covered.
-| Action | How |
-| --------------------- | ----------------------------------------------- |
-| Move the window | Drag the empty strip in the header |
-| Resize | Drag any edge or corner |
-| Dock / undock | Panel button in the header |
-| Resize while docked | Drag the panel's inner edge |
-| Lock / unlock | Padlock button in the header |
-| Collapse / expand | Chevron button in the header |
-| Collapse a dock | Panel button in the header, then click the rail |
-| Show / hide this tab | The corner launcher or rail, the ✕ button, or the toolbar icon |
-| Switch queries | Click the query name in the header |
-| Edit queries | Query menu → **Manage queries** |
-| Open an item | Click a row (⌘/Ctrl-click for a tab) |
-| Refresh one row | Right-click it → **Refresh this item** |
-| Pin a row to the top | Right-click it → **Pin item** (again to unpin) |
-| See a stack | Chevron on a stacked row, or right-click → **Show the stack** |
+It hangs below the site header so it reads as part of the page rather than on
+top of it. On a viewport with no gutter to spare, the page body is moved across
+to make room instead of being covered, while the header goes on spanning the
+window. Collapse it to a rail down the edge when you want the room back — it
+keeps counting.
-## Architecture
-
-```
-src/
- background/ Service worker: GitHub API proxy, IndexedDB cache, dev reload
- content/ Shadow-DOM mount, font injection, colour-mode sync, page metrics
- components/ Window chrome, list, rows, and shadcn-style primitives
- hooks/ Storage sync, window geometry, dock layout, search, cache updates
- lib/ GitHub GraphQL client, message protocol, storage schema
- options/ Settings page
-scripts/ Font copying, dev watch server
-scripts/ Font copying, dev watch server
-```
-
-A few decisions worth knowing:
-
-- **All GitHub requests go through the background worker.** github.com's
- Content-Security-Policy applies to `fetch` from a content script, so calls
- made there would be blocked. The worker also keeps the token out of page
- context.
-- **One GraphQL search per refresh.** The REST search API returns no CI or
- review data, which would mean an extra request per row. A single
- `search(type: ISSUE_ADVANCED)` GraphQL query returns state,
- `statusCheckRollup`, and `reviewDecision` for every result, so polling costs
- one request.
-- **`ISSUE_ADVANCED`, not `ISSUE`.** `ISSUE` is still the legacy query parser,
- which does not understand advanced syntax and does not complain about it — it
- matches nothing instead, so `(label:a OR label:b)` comes back empty rather
- than failing. The two agree on everything the legacy parser did understand,
- with one exception worth knowing: a space between `repo:`, `org:`, or `user:`
- qualifiers now means AND where it used to mean OR, so a saved query like
- `repo:acme/api repo:acme/web` has to be written
- `repo:acme/api OR repo:acme/web`.
-- **The UI lives in a shadow root** with its stylesheet adopted at runtime, so
- GitHub's CSS cannot reach it and vice versa. Because the shadow host carries
- `all: initial`, base typography is applied to the container inside the shadow
- tree, where it wins over that inline style.
-- **Geometry is written straight to the DOM** during a drag and only committed
- to storage on release, so dragging never re-renders the list. The window's
- `style` prop is frozen at mount for the same reason: recomputing it would let
- an unrelated re-render snap the window back mid-gesture.
-- **`@property` does not work inside a shadow root.** Tailwind registers its
- internal custom properties that way, so in the shadow tree they have no
- initial value and every declaration built on one — `box-shadow`,
- `border-style`, the transform stack — computes to nothing. Tailwind emits the
- same values as plain declarations behind an `@supports` test for engines
- without `@property` at all, which Chrome fails, so the stylesheet is rewritten
- at mount to drop that guard. Without it the panel has no border and no shadow.
-
-## Docked mode
+#### Finding the gutter
github.com centres its pages in a max-width column, which on a wide display
leaves a wide empty gutter down the left. Docked mode puts the panel there, so
@@ -160,6 +78,8 @@ selector would break the first time github.com reorganised its markup, so
flush at the top. A bar also only has to be as wide as the page column, since
the mini header is not full-bleed.
+#### Making room when there is none
+
When the gutter is too narrow, the page is moved across by padding `` —
the one element Turbo navigation never replaces, so the reservation survives
navigation without being reapplied. The full width is reserved rather than just
@@ -177,34 +97,45 @@ inset. A header stopping short of the window is the one thing that would give
away that the page has been moved at all, and it is the part of github.com a
reader is most likely to know by sight.
-## Open state is per tab
+#### Collapsing a dock
-Whether the panel is showing belongs to the tab, not the user, so it is the one
-piece of state that is not in `chrome.storage.local`. Sharing it would mean
-opening the panel once opened it in every tab from then on, which is the
-opposite of a new tab starting out of the way.
+A floating window collapses up into its own header. A docked one has nowhere to
+fold, so it collapses sideways into a rail down the edge of the gutter instead.
-Instead the service worker keeps a flag in `chrome.storage.session` against the
-tab's id. That gives each tab its own answer, survives reloads and navigation
-within a tab, is cleared when the browser closes, and — unlike `sessionStorage`
-— writes nothing to github.com's own storage. Tab ids get reused, so the flag is
-dropped on `tabs.onRemoved`; otherwise the next tab to take that id would
-inherit a panel nobody opened.
+The rail is deliberately not a hidden edge: something has to stay on screen or
+there is nothing left to click to bring the panel back. At 44px it fits in the
+gutter of any page, so collapsing hands back whatever room had been taken from
+the layout, and the whole height of it is one button. It keeps showing the
+result count, which is the reason to leave it on screen rather than hide it.
-Out of the way is not the same as gone, though, so a tab that has not been
-asked still leaves something to ask with: floating, a launcher in the corner;
-docked, the rail, exactly where the panel would have been. Neither costs a
-request — a rail is not an open panel — but neither sends anyone to the
-browser's extensions menu to find a panel that is meant to be part of the page.
+The same rail stands in for a docked panel that is hidden in this tab, or that
+has never been opened in it. Two states, one mark, and a click clears both at
+once — expanding out of a rail always ends with the panel, never with the rail
+redrawn because the other flag was still set.
-Opening a panel for the first time does not mean going to the network. The
-request goes to the worker like any other, which answers from its shared
-IndexedDB cache and only refreshes if what it has has aged out.
+Results therefore go on loading while a dock is collapsed — a count that stops
+updating is decoration, not status. That is the one place the two collapsed
+states differ: a collapsed floating window shows nothing, so it stops asking.
+A rail standing in for a hidden panel stops asking too; it is showing that the
+panel is there, not what is in it.
-Everything else about the window — size, position, docked, collapsed, locked —
-stays shared, because those are preferences rather than per-tab facts.
+Saved queries — any number of named GitHub searches, switchable from the header menu.
+
+Written in the same advanced syntax github.com's own search uses — `AND`, `OR`,
+and parentheses included.
-## Row states
+Live status — state, merge queue, CI rollup, and review decision on every row.
+
+Refreshed on a configurable interval. Right-click any row to refresh just that
+one on demand.
+
+#### Row states
The leading mark carries both what a row is and where it stands:
@@ -223,7 +154,7 @@ A queued pull request is still open, so GitHub reports it as `OPEN` with
the reader any more — it is on its way in — which is worth telling apart at a
glance from an open pull request that is still waiting on someone.
-## Refreshing a single row
+#### Refreshing a single row
Polling refreshes a whole query on an interval. That is the wrong tool for
watching one pull request's checks go green, so a row's context menu re-reads
@@ -241,7 +172,10 @@ fragments. A row that arrives on its own has to be indistinguishable from the
same row arriving through a search, or refreshing one would quietly drop a
badge.
-## Stacked pull requests
+Stacked pull requests — a
A stack is a chain of pull requests where each one targets the branch of the
one below it. GitHub exposes membership directly: `PullRequest.stackEntry`
@@ -264,36 +198,20 @@ Only the first 20 layers of a stack are read per row, but `size` comes from
GitHub, so a deeper stack still reports its real size and says how many layers
it is not showing.
-### Pinned rows
+layer/size badge and the whole chain from its own chevron.Pinned rows — lift the rows you are watching to the top, whichever query they turn up in.
Pins are stored as node ids under `pinnedIds`, apart from the queries that
surface them, so a pinned row keeps its place whichever query it turns up in.
Only rows already loaded can be lifted, so a pin on an item further down a
result set surfaces once its page arrives.
-### Collapsing a dock
-
-A floating window collapses up into its own header. A docked one has nowhere to
-fold, so it collapses sideways into a rail down the edge of the gutter instead.
-
-The rail is deliberately not a hidden edge: something has to stay on screen or
-there is nothing left to click to bring the panel back. At 44px it fits in the
-gutter of any page, so collapsing hands back whatever room had been taken from
-the layout, and the whole height of it is one button. It keeps showing the
-result count, which is the reason to leave it on screen rather than hide it.
-
-The same rail stands in for a docked panel that is hidden in this tab, or that
-has never been opened in it. Two states, one mark, and a click clears both at
-once — expanding out of a rail always ends with the panel, never with the rail
-redrawn because the other flag was still set.
+Cached across tabs — one shared IndexedDB cache in the service worker, so opening tabs costs no API calls.
Every github.com tab runs its own copy of the UI, so a naive setup would make
one API call per tab. Instead the service worker owns a single IndexedDB cache
@@ -325,6 +243,13 @@ The pieces that make that work:
- **Refreshes are pushed, not pulled.** When the active tab triggers a refresh,
the worker broadcasts the result to every open tab, which update their caches
without making their own request.
+- **A background refresh says so.** Serving the cached copy resolves the tab's
+ own request immediately, so the network call that follows it happens with
+ nothing in flight locally to report. The page carries a `revalidating` flag
+ instead, and a green hairline along the header's bottom border pulses — with
+ a lighter crest running left to right across it — until the broadcast lands.
+ It is held briefly so a fast refresh does not flicker, and capped so a
+ refresh that failed cannot leave the panel looking busy forever.
- **The refresh window follows your setting.** The refresh interval you choose is
exactly how long a cached page is served for, with a 15s floor so a short
interval cannot flood the API. With polling off, cached pages last 5 minutes.
@@ -332,6 +257,124 @@ The pieces that make that work:
first, so it is a true forced refresh. Entries older than a day are pruned on
startup.
+Fast lists — cursor-paginated infinite scroll with windowed virtualisation.
+
+Only the visible rows are ever in the DOM.
+
+Opens in a new window — clicking a row pops the item out.
+
+⌘/Ctrl-click opens a tab instead.
+
+