Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
33 changes: 33 additions & 0 deletions .github/workflows/pages.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
name: Deploy site

# Publishes site/ to GitHub Pages (https://devudilip.github.io/ration).
# One-time repo setting required: Settings → Pages → Source: GitHub Actions.
on:
push:
branches: [main]
paths: ['site/**', '.github/workflows/pages.yml']
workflow_dispatch:

permissions:
contents: read
pages: write
id-token: write

concurrency:
group: pages
cancel-in-progress: true

jobs:
deploy:
runs-on: ubuntu-latest
environment:
name: github-pages
url: ${{ steps.deployment.outputs.page_url }}
steps:
- uses: actions/checkout@v4
- uses: actions/configure-pages@v5
- uses: actions/upload-pages-artifact@v3
with:
path: site
- id: deployment
uses: actions/deploy-pages@v4
3 changes: 3 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,9 @@ Adding a provider is designed to be a single-file contribution — see

## Install

**Website:** <https://devudilip.github.io/ration> · **Chrome Web Store:**
coming soon (see `docs/store/` for the submission package).

**Option A — from a release (no build tools needed):**

1. Download `ration-vX.Y.Z.zip` from the
Expand Down
126 changes: 126 additions & 0 deletions docs/store/listing.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,126 @@
# Chrome Web Store listing — copy-paste source

Everything below maps 1:1 to fields in the
[Chrome Web Store developer console](https://chrome.google.com/webstore/devconsole).
Keep this file updated when the listing changes — it is the source of truth.

## Store listing tab

**Name**

```
Ration — AI Quota Tracker
```

**Summary** (132 chars max)

```
One glance at remaining quota across your AI subscriptions — Claude, Codex, and more. No accounts, no telemetry.
```

**Description**

```
If you pay for more than one AI tool, you know the drill: every provider
meters usage differently, resets on a different cycle, and buries the
number behind a different settings page. Nobody checks three dashboards
before starting a long task — so nobody checks at all, until they get cut
off mid-run.

Ration puts all of it one click away:

• Provider cards sorted by headroom — the subscription with the most
capacity is on top, because that's the answer to "which tool should I
use right now?"
• Every limit as its own bar: session windows, weekly limits,
model-specific pools — with remaining percentage and reset countdown.
• A toolbar badge showing the lowest headroom across your providers — the
wall you'll hit first. Quiet when everything is fine, amber under 40%,
red under 15%.
• Supported today: Claude (claude.ai) and Codex (chatgpt.com). More
providers are added by the open-source community — one file per
provider.

PRIVACY, BY DESIGN
• No account, no sign-up, no backend. Ration talks only to the provider
sites you enable.
• Zero telemetry and zero analytics. The extension has no runtime
dependencies at all — the entire behavior is auditable in the source.
• It never reads your cookies or stores any credential. It simply rides
the login you already have in your browser, exactly like opening the
provider's own usage page.
• Each provider's site access is an OPTIONAL permission, requested only
when you switch that provider on.
• Everything is stored locally in your browser and can be wiped with one
click.

HONEST BY DESIGN
Ration reads the same internal endpoints the providers' own settings pages
use. When a provider changes something, the affected card shows an error
state — never a wrong number. Fixes ship fast and the project is fully
open source: https://github.com/devudilip/ration
```

**Category**: Developer Tools
**Language**: English

**Screenshots**: upload `docs/store/screenshots/store-light.png` and
`store-dark.png` (1280×800).

## Privacy tab

**Single purpose description**

```
Ration displays the remaining usage quota of the AI subscription services
the user enables (e.g. Claude, ChatGPT/Codex), in a popup and a toolbar
badge, by reading each service's usage endpoint using the user's existing
browser session.
```

**Permission justifications**

- `storage`:

```
Caches the most recent quota readings and the user's provider on/off
settings locally (chrome.storage.local), so the popup renders instantly
and settings persist. Nothing is synced or transmitted anywhere.
```

- `alarms`:

```
Schedules a background quota refresh every 5 minutes per enabled
provider, so the toolbar badge stays current without keeping a
persistent background process.
```

- Optional host permission `https://claude.ai/*`:

```
Requested only when the user enables Claude tracking. Used solely to
call claude.ai's own usage endpoint with the user's existing browser
session, exactly like the user opening claude.ai Settings → Usage. No
page content is read or modified; no cookies are accessed.
```

- Optional host permission `https://chatgpt.com/*`:

```
Requested only when the user enables Codex/ChatGPT tracking. Used solely
to call chatgpt.com's own usage endpoint with the user's existing
browser session, exactly like the user opening ChatGPT Settings → Usage.
No page content is read or modified; no cookies are accessed.
```

**Remote code**: No, I am not using remote code.
(All code ships in the package; there are zero runtime dependencies and no
`eval`/remote script loading.)

**Data usage**: check **none** of the collection categories. Ration
collects no user data: nothing is transmitted to the developer or any
third party. Quota readings stay in the user's local browser storage.

**Certification checkboxes**: all three (complies with policies, data
usage is accurate, no prohibited use) can be checked truthfully.
Binary file added docs/store/screenshots/store-dark.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added docs/store/screenshots/store-light.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
64 changes: 64 additions & 0 deletions docs/store/submission-checklist.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
# Chrome Web Store submission checklist

A one-time walkthrough for publishing Ration. Budget ~30 minutes of form
filling plus a review wait of one to several days.

## 1. Register (once, $5)

1. Go to the [developer console](https://chrome.google.com/webstore/devconsole)
with the Google account that should own the listing.
2. Pay the one-time $5 registration fee and accept the developer agreement.
3. In **Account** settings, set a contact email and verify it (required
before you can publish).

## 2. Get the package

Use the zip from the release you're shipping — it's the exact build CI
tested:

- Download `ration-vX.Y.Z.zip` from
<https://github.com/devudilip/ration/releases/latest>.
- Do **not** re-zip a local build; the release artifact is reproducible
and its SHA-256 is recorded on the release.

## 3. Create the item

1. Developer console → **+ New item** → upload the zip.
2. Fill the **Store listing** tab from [`listing.md`](listing.md) —
name, summary, description, category, language.
3. Upload the screenshots from `screenshots/` (1280×800).
Optional but recommended: a 128×128 store icon is taken from the
manifest automatically.
4. Fill the **Privacy** tab from `listing.md` — single-purpose statement,
per-permission justifications, "no remote code", and the data-usage
section with **no** collection categories checked.
5. **Distribution** tab: visibility **Public**, all regions (default).

## 4. Submit, and what to expect from review

- Extensions requesting host access to high-value domains (chatgpt.com,
claude.ai) get extra scrutiny. Our mitigations are already in place and
stated in the listing: **optional** host permissions requested at
enable-time, no `cookies` permission, no remote code, zero runtime
dependencies, fully open source.
- If the review comes back with a rejection:
1. Read the cited policy carefully — most first-round rejections for
this category are about permission justification wording.
2. Reply/appeal pointing to: the optional-permission design (nothing is
accessed at install time), the single-purpose statement, and the
public source repository.
3. Do not weaken the manifest to get through review (e.g. don't drop
`optional_host_permissions` for content-script tricks) — the current
design is the honest and minimal one.
- Once published, note the item ID / store URL and:
- add the store link to `README.md` and `site/index.html` (replace the
"coming soon" button),
- keep future uploads in sync with git tags: each store update should be
a released version, uploaded from its release zip.

## 5. Updating the listing later

- New version: tag a release (`git tag vX.Y.Z && git push origin vX.Y.Z`),
download the zip from the release, upload it in the console, submit.
- Listing text changes: edit `listing.md` first, then paste into the
console — the repo copy is the source of truth.
156 changes: 156 additions & 0 deletions scripts/store-screenshots.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,156 @@
// Maintainer tool: renders the popup with sample data and produces
// - docs/store/screenshots/*.png (1280x800, Chrome Web Store format)
// - site/assets/popup-{light,dark}.png (bare popup, for the landing page)
//
// Not part of CI. Requires a build (npm run build) and Playwright with a
// Chromium; point PLAYWRIGHT_MODULE at a playwright install if it isn't
// resolvable, and CHROMIUM_PATH at a browser binary if needed.
// npm run build && node scripts/store-screenshots.mjs
import { createServer } from 'node:http';
import { readFile, mkdir } from 'node:fs/promises';
import { extname, join } from 'node:path';
import { fileURLToPath } from 'node:url';

const { chromium } = await import(process.env.PLAYWRIGHT_MODULE ?? 'playwright');

const root = fileURLToPath(new URL('..', import.meta.url));
const dist = join(root, 'dist');
const storeDir = join(root, 'docs/store/screenshots');
const siteDir = join(root, 'site/assets');

const now = Date.now();
const seed = {
'v1:settings': { providers: { claude: { enabled: true }, codex: { enabled: true } } },
'v1:snapshot:claude': {
providerId: 'claude',
displayName: 'Claude',
status: 'ok',
schemaVariant: 'org_usage_limits',
adapterVersion: 2,
fetchedAt: new Date(now - 40_000).toISOString(),
lanes: [
{ id: 'session', label: 'Session (5h)', kind: 'percent', used: 14, limit: 100, resetsAt: new Date(now + 68 * 60_000).toISOString(), headroomPct: 86 },
{ id: 'weekly_all', label: 'Weekly (all models)', kind: 'percent', used: 5, limit: 100, resetsAt: new Date(now + (4 * 24 + 18) * 3_600_000).toISOString(), headroomPct: 95 },
{ id: 'weekly_scoped:Fable', label: 'Weekly (Fable)', kind: 'percent', used: 7, limit: 100, resetsAt: new Date(now + (4 * 24 + 18) * 3_600_000).toISOString(), headroomPct: 93 },
],
},
'v1:snapshot:codex': {
providerId: 'codex',
displayName: 'Codex',
status: 'ok',
schemaVariant: 'wham_rate_limit',
adapterVersion: 2,
fetchedAt: new Date(now - 55_000).toISOString(),
lanes: [
{ id: 'session', label: 'Session (5h)', kind: 'percent', used: 24, limit: 100, resetsAt: new Date(now + (3 * 60 + 38) * 60_000).toISOString(), headroomPct: 76 },
{ id: 'weekly', label: 'Weekly', kind: 'percent', used: 4, limit: 100, resetsAt: new Date(now + (6 * 24 + 17) * 3_600_000).toISOString(), headroomPct: 96 },
{ id: 'extra:gpt-reserve', label: 'gpt-reserve', kind: 'percent', used: 0, limit: 100, resetsAt: new Date(now + 7 * 24 * 3_600_000).toISOString(), headroomPct: 100 },
],
},
};

const stub = `
const store = ${JSON.stringify(seed)};
globalThis.chrome = {
storage: {
local: {
get: async (keys) => keys === null ? store : Object.fromEntries((typeof keys === 'string' ? [keys] : keys).filter(k => k in store).map(k => [k, store[k]])),
},
onChanged: { addListener: () => {} },
},
runtime: { sendMessage: async () => ({ ok: true }), getManifest: () => ({ version: '0.1.1' }) },
permissions: { request: async () => true, remove: async () => true },
};
`;

const wrapper = (dark, popupHeight) => `<!doctype html>
<html><head><meta charset="utf-8"><style>
body { margin: 0; width: 1280px; height: 800px; display: flex; align-items: center;
font-family: system-ui, sans-serif; overflow: hidden;
background: ${dark
? 'linear-gradient(135deg, #0b1220 0%, #1f2937 60%, #263548 100%)'
: 'linear-gradient(135deg, #e8eef7 0%, #f6f8fb 55%, #dde7f3 100%)'}; }
.copy { flex: 1; padding: 0 40px 0 80px; color: ${dark ? '#f3f4f6' : '#1f2937'}; }
.copy h1 { font-size: 44px; line-height: 1.15; margin: 0 0 18px; letter-spacing: -0.02em; }
.copy p { font-size: 20px; line-height: 1.5; margin: 0; color: ${dark ? '#9ca3af' : '#4b5563'}; }
.frame { margin-right: 110px; border-radius: 14px; overflow: hidden;
box-shadow: 0 24px 70px rgba(0,0,0,${dark ? '0.55' : '0.22'});
border: 1px solid ${dark ? '#374151' : '#d7dee8'}; }
iframe { display: block; width: 356px; height: ${popupHeight}px; border: 0;
color-scheme: ${dark ? 'dark' : 'light'}; }
</style></head><body>
<div class="copy">
<h1>One glance at every AI quota</h1>
<p>Claude, Codex, and more — sorted by which subscription has the most headroom right now. No accounts. No telemetry. Everything stays in your browser.</p>
</div>
<div class="frame"><iframe src="/popup.html"></iframe></div>
</body></html>`;

const MIME = { '.html': 'text/html', '.js': 'text/javascript', '.css': 'text/css', '.png': 'image/png', '.json': 'application/json' };
const server = createServer(async (req, res) => {
try {
const m = req.url.match(/^\/wrapper-(dark|light)-(\d+)/);
if (m) {
res.writeHead(200, { 'Content-Type': 'text/html' });
return res.end(wrapper(m[1] === 'dark', Number(m[2])));
}
const body = await readFile(join(dist, req.url === '/' ? 'popup.html' : req.url));
res.writeHead(200, { 'Content-Type': MIME[extname(req.url)] ?? 'application/octet-stream' });
res.end(body);
} catch {
res.writeHead(404).end();
}
});
await new Promise((ok) => server.listen(8123, ok));

await mkdir(storeDir, { recursive: true });
await mkdir(siteDir, { recursive: true });

const browser = await chromium.launch({
executablePath: process.env.CHROMIUM_PATH || undefined,
args: ['--no-sandbox'],
});

// Measure the seeded popup's natural height so frames fit the content exactly.
const probe = await browser.newPage({ viewport: { width: 356, height: 800 } });
await probe.addInitScript(stub);
await probe.goto('http://localhost:8123/popup.html');
await probe.waitForTimeout(400);
const popupHeight = await probe.evaluate('document.body.scrollHeight');
await probe.close();
console.log('measured popup height:', popupHeight);

// 1280x800 store shots
for (const dark of [false, true]) {
const page = await browser.newPage({
viewport: { width: 1280, height: 800 },
colorScheme: dark ? 'dark' : 'light',
deviceScaleFactor: 1,
});
await page.addInitScript(stub);
await page.goto(`http://localhost:8123/wrapper-${dark ? 'dark' : 'light'}-${popupHeight}`);
await page.waitForTimeout(400);
const file = join(storeDir, `store-${dark ? 'dark' : 'light'}.png`);
await page.screenshot({ path: file });
console.log('wrote', file);
await page.close();
}

// bare popup shots for the landing page
for (const dark of [false, true]) {
const page = await browser.newPage({
viewport: { width: 356, height: popupHeight },
colorScheme: dark ? 'dark' : 'light',
deviceScaleFactor: 2,
});
await page.addInitScript(stub);
await page.goto('http://localhost:8123/popup.html');
await page.waitForTimeout(400);
const file = join(siteDir, `popup-${dark ? 'dark' : 'light'}.png`);
await page.screenshot({ path: file });
console.log('wrote', file);
await page.close();
}

await browser.close();
server.close();
Binary file added site/assets/popup-dark.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added site/assets/popup-light.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added site/favicon.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Loading