# onym.dev website — design spec

**Date:** 2026-04-24 (rev. 2026-04-25 after Codex review)
**Status:** approved for implementation planning
**Scope:** the public website at `onym.dev`

## Summary

`onym.dev` is a single-page, terminal-styled website that lets visitors check a
name's availability across package registries, GitHub, and TLDs directly in the
browser. It reuses the `onym` crate's source adapters and classification logic
compiled to WebAssembly, paired with a wasm-specific transport layer in the
browser. An edge proxy is held in reserve for any source that proves
CORS-blocked at audit time; the implementation plan must verify per-source
CORS before declaring the proxy unnecessary.

The site's job is two things at once:

1. Make the CLI's value felt immediately — type a name, see classification.
2. Sell the CLI itself — install block at the bottom, with the website as the
   live demo.

## Goals

- Zero-friction demo: paste a name, see results in seconds.
- Preserve the CLI's signature feature — **classification** (Active /
  Placeholder / Dead / Unknown) — and make it the site's main narrative.
- Keep infra minimal. No rate limits on our side for the big registries.
- Shareable permalinks (`onym.dev/<name>`) so results are linkable.
- Deepen `onym`'s identity as a CLI by looking like one.

## Non-goals

- No login, no user accounts, no saved history server-side.
- No marketing blog, no multi-page docs site. CLI docs live in the README.
- No analytics beyond a minimal privacy-respecting page-view counter (or none).
- No feature parity with the CLI's `--output json` / scripting use cases; the
  site is for humans.

## Architecture

**Browser-first with proxy fallback.** Every source is browser-direct unless
the implementation audit proves CORS is blocked.

```
[ browser + WASM ] ──▶ crates.io · pypi · npm
[ browser + WASM ] ──▶ api.github.com/users/<name>
[ browser + WASM ] ──▶ RDAP authoritative endpoints (per-TLD)
[ browser + WASM ] ──▶ [ edge proxy ] ──▶ any source that fails CORS audit
```

### What ports cleanly to wasm

The crate's pure-logic modules — `classify.rs`, `validate.rs`, `types.rs`, and
the parsing/response shapes inside `sources/*` — port to `wasm32-unknown-unknown`
unchanged.

### What does NOT port and must be replaced

The current crate is **not wasm-buildable as-is**:

- `Cargo.toml:20` enables `tokio = { features = ["macros", "rt-multi-thread"] }`.
  `rt-multi-thread` is unsupported on `wasm32` — enabling it fails to compile.
  This is the only hard compile blocker. The CLI binary needs `rt-multi-thread`
  for `#[tokio::main]`, so the wasm crate must depend on the library with a
  feature flag that gates this away (e.g. a `native` default feature).
- `lib.rs::check_name` returns `NameReport` only after every task finishes
  (`while let Some(joined) = set.join_next().await`). The browser needs
  per-source streaming and cancellation — a different orchestration API.

Other notes for the implementer (not blockers, but worth knowing):

- Reqwest's wasm `ClientBuilder` does support `.user_agent(...)`, and
  `.timeout(...)` is available per-request on wasm. The current
  `build_client` shape is reusable; only the transport target differs.
- `tokio::task::JoinSet` exists on wasm via `spawn_local`, but using
  `futures::stream::FuturesUnordered` avoids the `Send` bound and is the
  cleaner choice for browser code. This is a design call, not a compile
  necessity.

The implementation introduces a **transport facade** that lives in the main
`onym` crate — not in a wasm-private module — so adapters can be reused
without duplication:

- A `transport` trait in the `onym` crate abstracts the small surface every
  adapter needs: `get_json`, `get_status_only`, `head_status`. Adapters take
  `&impl Transport` instead of `&reqwest::Client`.
- The native binary provides a `ReqwestTransport` impl backed by
  `reqwest::Client` — behavior identical to today.
- The wasm crate provides a `FetchTransport` impl backed by `gloo_net` (or
  raw `fetch` via `wasm_bindgen_futures`), with timeout via `AbortController`
  and per-request cancellation.
- A separate streaming orchestrator (new, **not** the existing `check_name`)
  drives per-source progress for the website. `check_name` stays as the
  batch API for the CLI; the new API yields a stream of
  `(source, CheckResult)` events suitable for piping to the UI.
- This is a **public library API change** to the `onym` crate's `sources/*`
  signatures, but the changes are purely additive at the crate level (the
  CLI keeps working through the new transport).

### GitHub: switch to the REST API

The CLI uses `HEAD https://github.com/<name>` to avoid the API quota. That
HEAD endpoint is not CORS-accessible from a browser, but
`GET https://api.github.com/users/<name>` is — GitHub's docs explicitly state
the REST API supports CORS from any origin. The wasm build introduces a new
adapter that calls the REST API and maps `200 → Taken`, `404 → Free`. The
Worker is **not** required for GitHub.

The unauthenticated REST limit (60 req/hour per IP) is per-user, so it stays
out of our shared-IP failure domain — the same property as the registry
calls.

### Other sources: validated assumptions

CORS for crates.io, PyPI, npm, and the RDAP endpoints in
`src/sources/rdap.rs:7` (`identitydigital`, `pubapi.registry.google`,
`rdap.verisign.com`, `rdap.publicinterestregistry.org`) is widely observed
to work in browsers but **not contractually documented** by the providers.
RDAP's RFC 7480 only *recommends* CORS, it does not require it. The
implementation plan must run a per-source CORS audit (script + automated
test); any source that fails routes through the edge proxy as a fallback,
without changing the rest of the architecture.

### Edge proxy

A small Cloudflare Worker, included in the build but disabled by default. It
fronts only sources that fail the CORS audit, with:

- Per-IP rate limit (e.g. 30 req/min) to keep abuse off our shared IP.
- Short cache (e.g. 5–15 min KV TTL) for popular names.
- Allowlist of upstream hosts — the Worker refuses to be a generic open proxy.

If the CORS audit shows every source works directly, the Worker ships but is
not deployed.

### Rationale

Rate limits against major registries burn the user's IP, not ours. The
classification step happens client-side, so squatter detection works without
any server roundtrip. The Worker is a bounded fallback, not the primary
runtime.

## Visual direction

**Terminal-native.** Dark background, monospace throughout, ANSI-style accent
colors (green for free, red for taken, amber for placeholder, dim gray for
dead). The design commits to looking like the CLI rather than like every other
dev-tool landing page. The wordmark is the project name in mono with no logo.

Accent palette:
- Background `#0a0a0a`, surface `#0f0f0f`, border `#1f1f1f`
- Text `#e6edf3`, muted `#8b949e`, dim `#6e7681`
- Free/OK `#3fb950`, Taken/Active `#f85149`, Placeholder `#d29922`

## Page structure

Single scrollable page, no routes beyond `onym.dev/<name>`.

### 1. Top bar

- `onym` wordmark (left).
- Right nav: `docs` · `github` · `install` (anchor links, not routes).

### 2. Hero (empty state)

- Small uppercase kicker: `// name availability, classified`
- Tagline: *See who actually owns a name — across registries, GitHub, and
  domains.*
- Input styled as a terminal prompt: `$ onym check ▮` with a blinking caret.
  Behind the styling it is a real `<input>`; clicking anywhere in the row
  focuses it. `⌘K` / `Ctrl-K` also focus.
- Example chips below the input: `zed · bun · ruff · astral · tailwind`.
  Clicking a chip runs the check.

### 3. Results (streaming, appears inline on search)

Replaces / grows below the hero input without navigation.

- **Summary row** at the top: `1 free · 5 taken (5 active) → exit 2 · ACTIVE
  project holds this name` + a `↗ share onym.dev/<name>` link on the right.
- **Rows**, one per source, in this fixed order matching the CLI's
  `default_tlds` (`["io", "dev", "com", "app"]` — see `src/sources/rdap.rs:7`):
  `crates.io → pypi → npm → github.com → .io → .dev → .com → .app`
- Each row streams through three visual states:
  1. `● checking` with a spinner (light gray)
  2. `● free` (green) or `● taken` (red) with metadata
  3. If taken, a classification badge: `ACTIVE` / `PLACEHOLDER` / `DEAD` /
     `UNKNOWN`
- Metadata per row reflects exactly what the underlying adapter populates —
  no fabricated data:
  - `crates.io`: version, last release, **recent downloads**
  - `pypi`: version, last release, summary (no downloads — adapter doesn't
    fetch them)
  - `npm`: version, last release, description (no downloads)
  - `github.com`: account exists / does not exist (no star or repo metadata)
  - TLDs: registrar name when the RDAP response includes one (the current
    adapter parses `entities[].roles="registrar"` only — registration date
    is not parsed today; a future enhancement, not assumed by this spec)
- Errors (network/timeout/ratelimit) render inline on the row as a dim
  message — never a page-level error.

### 4. Classification legend

Below results, always visible (not gated behind search).

- Label: `what makes onym different`
- Heading: *Classification: tells squatters from real projects*
- Four cards: **ACTIVE**, **PLACEHOLDER**, **DEAD**, **UNKNOWN**, each with a
  one-sentence meaning and the numeric heuristic in small print.
- This section is the argument for why onym exists.

### 5. Install block

- Label: `prefer the cli`
- Primary codeblock: `$ cargo install onym` with a copy button.
- Secondary line: `brew install rvben/tap/onym` · prebuilt binaries on GitHub
  releases.

### 6. Footer

- Left: `onym v<version> · MIT` (version populated at build time).
- Right: `github` · `crates.io` · `changelog`.

## Interaction model

- **Streaming-first results.** Rows render immediately with spinners, each
  source fills in independently. No skeleton-to-full swap; users see progress.
- **Permalinks:** submitting a search `pushState`s `/<name>` and the page
  re-runs on load when visited directly. `onym.dev` with no path is the empty
  hero.
- **Keyboard-first:** `⌘K` / `Ctrl-K` focuses input, `Enter` runs, `Esc`
  clears and returns to empty state.
- **Copy affordances:** the install block and the share permalink both have
  one-click copy with a short "copied" confirmation.

## Runtime requirements

These are non-visual implementation requirements that must be honored:

- **Input handling:** `Enter` triggers a check; typing alone does not. (The
  CLI is the model — you don't get auto-results as you type.) Empty input is
  a no-op.
- **Cancellation:** when a new check starts while a previous one is still
  in-flight, all pending fetches for the previous name are aborted via
  `AbortController`. Stale rows never overwrite fresh ones.
- **Timeouts:** each source has a 15s timeout (matches the CLI's
  `HTTP_TIMEOUT`). On timeout, the row renders an inline `timeout` message
  and does not block the others.
- **RDAP endpoint fallback:** RDAP iterates through a per-TLD list of
  authoritative endpoints (`src/sources/rdap.rs:51`). On `429`/`503` or any
  non-success status, it falls through to the next endpoint immediately —
  no sleep, no exponential backoff. The wasm transport must preserve this
  fallback-on-failure semantics; explicit retry-with-backoff is **not** in
  scope for this spec. Other sources do not retry.
- **Per-name local cache:** results for a given name live in `sessionStorage`
  for ~10 minutes so back/forward navigation and chip re-clicks are instant.
  Cache is keyed by name; revisiting the permalink hits the cache.
- **Worker (when deployed):** per-IP rate limit (30 req/min, configurable),
  KV cache (5–15 min TTL) for upstream responses, host allowlist that
  matches exactly the audited CORS-blocked sources. The Worker rejects
  unknown upstream hosts.
- **Concurrency:** the wasm orchestrator fans all sources out at once, the
  same shape as the CLI (`src/lib.rs:32`, no cap). With the default source
  list this is ~8 concurrent fetches; future TLD additions inherit the same
  pattern.

## Content decisions

- Tagline copy is dry / CLI-flavored, not marketing-flavored. Lowercase
  kickers, sentence-case body.
- Error messages mirror the CLI's vocabulary (`invalid_input`, `rate_limit`,
  `network`) so the site reinforces the CLI's mental model.
- No testimonials, no "built by" section, no trust logos.

## Out of scope (for this spec)

- Batch checks / CSV input.
- Saved lists, auth, history.
- Detailed per-source drill-down pages.
- Analytics beyond a single aggregate counter (decide later; default: none).
- Localization. English only.

## Open questions

- **Per-source CORS audit:** the implementation plan must run an automated
  preflight against crates.io, PyPI, npm, `api.github.com`, and each RDAP
  endpoint in `DEFAULT_TLDS`. Any source that fails routes through the edge
  proxy. This audit determines whether the Worker ships deployed or
  inert-by-default.
- **Bundle size:** first measurement after `wasm-pack build --release` will
  tell us whether code-splitting (e.g. lazy-loading RDAP) is worthwhile. The
  upper bound that makes the demo "fast" on cold load is ~250 KB gzipped;
  above that, plan to lazy-load.
- **Public API change to `onym`:** the transport facade requires changing
  every `sources/*::check` signature from `&Client` to `&impl Transport`,
  plus a new streaming orchestrator alongside `check_name`. The spec
  commits to this change (per Architecture); the open question is whether
  to bump the crate version major (the CLI currently has no external
  consumers per `CLAUDE.md`, so a 0.1.x patch is acceptable per the user's
  release policy).

## Repo layout

The website lives in the existing `onym` repo as a Cargo workspace. Reasons:

- The WASM build depends directly on the crate; any change to `sources/` or
  `classify.rs` needs the site updated in the same PR.
- One place to star, one place to find everything.
- A workspace cleanly isolates wasm-only dependencies (`gloo_net`,
  `wasm-bindgen`, `wasm-bindgen-futures`) from the native CLI.

```
onym/                        # workspace root
  Cargo.toml                 # workspace manifest
  crates/
    onym/                    # current CLI crate (renamed dir, same name)
      src/
        transport.rs         # NEW: Transport trait + ReqwestTransport impl
        sources/             # adapters refactored to take &impl Transport
        ...                  # other modules unchanged
    onym-wasm/               # new wasm-only crate
      src/                   # FetchTransport + streaming orchestrator + JS bindings
  web/
    site/                    # HTML, CSS, JS glue, wasm-pack output
    worker/                  # edge proxy (Cloudflare Worker, minimal)
```

The native CLI binary continues to depend only on `crates/onym`; the wasm
crate depends on `crates/onym` with `default-features = false` and disables
the tokio/reqwest features that don't compile on `wasm32`.

## Hosting

Provisionally: static files on Cloudflare Pages, edge proxy as a Cloudflare
Worker on the same zone. Final choice deferred to the implementation plan —
Vercel, Netlify, or Fly.io edge all work. The spec requires only that the
hosting platform supports static files + a small edge function on the same
origin.
