Docs: split AGENTS.md into root + frontend + api; slim CLAUDE.md

CLAUDE.md is now a 10-line pointer to AGENTS.md plus a Claude-specific
extension slot; everything contributor-facing lives in AGENTS.md.

Root AGENTS.md is the index — Overview, Stack, Commands, Project layout,
universal Conventions (language / comments / i18n), Testing, a short
Security & Boundaries summary, and Workflow. It references
@frontend/AGENTS.md and @api/AGENTS.md for area-specific details.

frontend/AGENTS.md collects the UI system (primitives, design tokens,
status tones, canonical patterns) plus the shadcn-vue-first rule and
frontend-specific project layout / conventions. This is what a UI
contributor needs — API rules never get in the way.

api/AGENTS.md covers the backend handler shape (fetchUpstream, error
shape, response normalization), the middleware model (requireReferer
globally, requireValidIP per-route, handlers must not repeat those
checks), the private-API header pass-through exception, and the smoke
test layout.

New rules captured across the three files:

- Every new file opens with a header comment stating its purpose
- Large templates / functions carry block comments on each meaningful section
- Big features and modifications to tested features update tests in the
  same change; `npm run check` must be green before handing off
- One concern per commit
- Visual changes get an explicit "please test in npm run dev" handoff
- User testing must pass before commit
- shadcn-vue-first for new UI components (local primitives → shadcn-vue
  library → hand-rolled Tailwind as last resort)
- Every commit scans AGENTS.md for staleness and updates it in the same
  change if the rules shifted

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
jason5ng32andClaude Opus 4.7 committed 2026-04-19 09:06:13 +08:00
1 parent 8a80724855
commit c7ef2cb810
4 files changed
+313 -167

No files matched your search

+69 -156
View File
@@ -1,5 +1,12 @@
# AGENTS.md
Single source of truth for anyone — human or AI — contributing to MyIP.
For area-specific details, see:
- Frontend (Vue 3 SPA): @frontend/AGENTS.md
- Backend API (Express): @api/AGENTS.md
## Overview
**MyIP** (IPCheck.ing) is an open-source IP toolbox: IP lookup, connectivity tests, WebRTC / DNS-leak detection, speed test, MTR, Whois, security checklist, browser fingerprint, anonymity checks, and more.
@@ -27,155 +34,6 @@ Single repo, two halves: a Vue 3 SPA front-end and an Express 5 back-end API, se
| Tests | Node built-in test runner (`node --test`) |
| Runtime libs | chart.js · svgmap · @cloudflare/speedtest · maxmind · whoiser · thumbmarkjs · ua-parser-js · detect-gpu · circle-progress.vue |
## Project layout
```
.
├── AGENTS.md ← this file (single source of truth)
├── CLAUDE.md ← pointer to AGENTS.md
├── refactor/ ← archived design notes
│
├── index.html ← #app mounts vaul-drawer-wrapper
├── vite.config.js
├── jsconfig.json ← JS project, alias @ → frontend/
├── package.json
│
├── frontend/ ← Vite root
│ ├── App.vue ← thin shell, delegates to composables
│ ├── main.js
│ ├── store.js ← Pinia main store
│ ├── firebase-init.js
│ ├── sw.js ← Serwist service worker
│ ├── router/ ← Advanced Tools subroutes (opens Drawer)
│ ├── locales/ ← i18n copy (en / zh / fr / tr) + security-checklist data
│ ├── style/style.css ← Tailwind v4 entry + design tokens
│ ├── lib/ ← cn() helper (tailwind-merge + clsx)
│ ├── data/ ← static config (achievements / ip-databases / sections / default-preferences)
│ ├── utils/ ← pure helpers (valid-ip / getips / transform-ip-data / shortcut impls / …)
│ ├── composables/ ← reusable composition logic
│ │ ├── use-info-mask.js
│ │ ├── use-refresh-orchestrator.js
│ │ ├── use-scroll-to.js
│ │ ├── use-section-tracking.js
│ │ ├── use-shortcuts.js
│ │ └── use-status-tone.js ← shared 4-tier business-state → visual-color mapping
│ └── components/
│ ├── *.vue ← top-level sections (IpInfos / Connectivity / WebRTC / DnsLeaks / SpeedTest / Advanced / Footer / Nav / Achievements / User / Additional)
│ ├── ip-infos/ ← IP-card subcomponents (IPCard / ASNInfo / DataPairBar)
│ ├── advanced-tools/ ← 10 tool subpages (MtrTest / GlobalLatencyTest / RuleTest / DnsResolver / CensorshipCheck / Whois / MacChecker / BrowserInfo / InvisibilityTest / SecurityChecklist + Empty)
│ ├── widgets/ ← small reusables (QueryIP / Help / Preferences / InfoMask / PWA / Toast)
│ ├── svgicons/ ← a few inline SVGs
│ └── ui/ ← shadcn-vue copy-in primitives (see UI system)
│
├── api/ ← backend API handlers
├── common/ ← code shared between front- and back-end (valid-ip / maxmind-db / …)
├── public/ ← static assets
├── tests/ ← Node test runner specs
├── backend-server.js ← Express API (default port 11966)
└── frontend-server.js ← static file server for `npm start`
```
## UI system
### Primitives
Located at `frontend/components/ui/`. 21 primitives are copied in:
`accordion` · `badge` · `button` · `button-group` · `card` · `collapsible` · `dialog` (with `DialogHeader`) · `drawer` (vaul-vue) · `dropdown-menu` · `input` · `input-group` · `progress` · `select` · `separator` · `sheet` · `sonner` · `spinner` · `switch` · `tabs` · `toggle-group` · `tooltip`
Three are project-specific, not in stock shadcn-vue:
- **`ButtonGroup`** — visual container for stitching buttons together
- **`InputGroup`** — flattens `Input + Button` (or `Input + Select`) into a single bordered control via `[&>input] / [&>button]` passthrough selectors. The group itself carries border / ring / shadow. Use this whenever an input is paired with a trigger button — never hand-roll `rounded-r-none / -ml-px`
- **`Spinner`** — lucide `Loader2` + `animate-spin` + `role="status"`
Rule: before authoring a `<button>`, `<input>`, popover, or list, check `frontend/components/ui/` first. Missing variants are not a reason to bypass the primitive — `:class` overrides, `as-child`, and tw-merge cover nearly every state-color need. Only drop to raw Tailwind when the shape or behavior does not exist.
### Design tokens
Tokens live at the top of `frontend/style/style.css`. On top of shadcn defaults, four business-semantic colors are defined:
| Token | Meaning | Light | Dark |
|---|---|---|---|
| `--info` | waiting / in-progress | sky-500 | sky-400 |
| `--success` | ok-fast / success | green-500 | green-400 |
| `--warning` | ok-slow / warning | amber-500 | amber-400 |
| `--action` | the "run / trigger" brand color | blue-500 | blue-500 |
`--destructive` uses the shadcn default. Every token has a paired `-foreground`; `bg-*` consumers do not need `dark:` variants.
Rule: use semantic tokens (`bg-info` / `bg-success` / `bg-warning` / `bg-destructive` / `bg-action` / `bg-muted` / `bg-card` / `bg-accent` / `text-foreground` / `text-muted-foreground` / `border` / …). Do not write `dark:` dual pairs like `bg-neutral-50 dark:bg-neutral-900` — that pattern does not exist in this codebase.
### Button and Badge variants
Button adds `action` (blue-500 trigger color) to the shadcn defaults (`default / destructive / outline / secondary / ghost / link`).
Badge adds `success` to the defaults. Badge hover is globally disabled — Badge is a display element, not an interactive one. Put `hover:bg-muted` etc. on an outer `<a>` / `<button>` when interactivity is needed.
### Status tones
All "business state → visual color" mapping goes through `frontend/composables/use-status-tone.js`. It exposes four tones: `wait` / `ok-fast` / `ok-slow` / `fail`.
Rule: any new module that surfaces a status reuses these tones. Do not hand-write `switch` statements that map states to hex codes or Tailwind classes.
### Canonical patterns
**Trigger button** — `variant="action"` + `<Spinner />` + `:disabled` is the three-piece pattern used by QueryIP, MacChecker, DnsResolver, Whois, MtrTest, Ping, Censorship, Invisibility, etc.
```vue
<Button variant="action" :disabled="isRunning" @click="run">
<Spinner v-if="isRunning" />
Run
</Button>
```
**Input with trigger** — `InputGroup` flattens the pair. `SelectTrigger` renders as a button, so `Select` can swap in for `Input` with no extra work.
```vue
<InputGroup>
<Input v-model="q" placeholder="..." />
<Button variant="action" :disabled="running" @click="run">
<Spinner v-if="running" /> Run
</Button>
</InputGroup>
```
**Status card** — homepage status cards (Connectivity / WebRTC / DnsLeak / IPCard / RuleTest) use:
```vue
<Card class="keyboard-shortcut-card jn-card transition-transform duration-300 ease-out hover:-translate-y-1.5">
```
`jn-card` is the project marker for shadow / border / keyboard-nav outline. `keyboard-shortcut-card` is the marker that the J/K keyboard-shortcut navigation queries for.
**Flag** — always use the iconify circle-flags set:
```vue
<Icon :icon="'circle-flags:' + code.toLowerCase()" class="size-4" />
```
**Tables vs lists** — two or three columns with independent header semantics → `<table>` with `thead text-xs uppercase tracking-wide text-muted-foreground` + `tbody divide-y` + row `hover:bg-muted/50`. Mobile-friendly rows or asymmetric content with no header meaning → `<ul class="rounded-lg border bg-card divide-y">` + `<li>`.
**Dialog header** — use the `<DialogHeader :icon="SomeIcon" :title="..." />` primitive to get an icon, title, and DialogClose in one element, matching the Sheet / Drawer header treatment.
**Drawer** — reserved for the bottom drawer (Advanced Tools panel), via `vaul-vue`. Side panels (right / left) use `Sheet`. The Drawer defaults to `handleOnly: true` (only the top handle is draggable) and CSS is set so body text remains selectable.
**Motion** — card hover lift: `transition-transform duration-300 ease-out hover:-translate-y-1.5`. Panel fade-slide: scoped `.fade-slide-*` transitions. Loading: `<Spinner />`, not hand-rolled `animate-pulse` dot clusters.
## Code conventions
- **JavaScript only.** New files are `.js` / `.vue`; `<script setup>` has no `lang="ts"`. Do not rename `jsconfig.json` to `tsconfig.json` or otherwise introduce TypeScript.
- **Composition API.** All components use `<script setup>`.
- **Path alias.** `@` → `frontend/` (defined in `jsconfig.json` and `vite.config.js`).
- **Comments are English by default.** Commit messages and planning docs can be Chinese; source-code comments stay English.
- **i18n coverage.** Any new feature that surfaces copy must land in all four locales at `frontend/locales/{en,zh,fr,tr}.json` in the same change. No English-only or Chinese-only keys.
## Testing
- **Test runner:** Node built-in (`node --test`), no third-party framework. Specs live in `tests/*.test.js`.
- **Coverage expectation:** any non-visual logic that can be exercised without a network call — pure functions, composables with mockable inputs, transform utilities, validators — ships with a test in `tests/` and is wired into `npm test`. UI rendering, real network behavior, and browser-API dependent code are out of scope.
- **When adding such logic, write the test in the same change.** Don't defer.
## Commands
| Command | What it does |
@@ -187,12 +45,67 @@ Rule: any new module that surfaces a status reuses these tones. Do not hand-writ
| `npm test` | Run all `tests/*.test.js` specs |
| `npm run check` | `test` + `build`, the pre-commit self-check |
## Project layout
```
.
├── AGENTS.md ← this file
├── CLAUDE.md ← Claude-specific pointer to AGENTS.md
│
├── frontend/ ← Vue 3 SPA (see frontend/AGENTS.md)
├── api/ ← Express handlers (see api/AGENTS.md)
├── common/ ← code shared between front- and back-end
│ (valid-ip / fetch-with-timeout / guards /
│ referer-check / maxmind-service / …)
├── tests/ ← Node test runner specs
│
├── backend-server.js ← Express app (default port 11966)
├── frontend-server.js ← static file server for `npm start`
├── index.html ← Vite entry; #app mounts vaul-drawer-wrapper
├── vite.config.js
├── jsconfig.json ← JS project, alias @ → frontend/
└── package.json
```
## Conventions
### Language
- **JavaScript only.** New files are `.js` / `.vue`; `<script setup>` has no `lang="ts"`. Do not rename `jsconfig.json` to `tsconfig.json` or otherwise introduce TypeScript.
- **English by default** for source-code comments , commit messages and AGENTS.md files. Planning docs can be in other languages. Locale packs are obviously the exception — they contain user-facing copy in their respective language.
### Comments
- **Every new file opens with a header comment** stating its purpose. One or two lines is usually enough; enough that a reader opening the file cold understands what it is.
- **Large templates or functions carry block comments** on each meaningful section — enough for a maintainer six months later to orient quickly. Not every line, but every region / branch / step.
### i18n coverage
- Any feature that surfaces copy must land in **all four locales** (`en` / `zh` / `fr` / `tr`) in the same change. No English-only or Chinese-only keys slipping through.
- Same rule applies to `frontend/data/changelog.json` — every entry's `change` object must have all four languages. `tests/changelog.test.js` enforces this.
## Testing
- **Test runner:** Node built-in (`node --test`), no third-party framework. Specs live in `tests/*.test.js`.
- **Coverage expectation:** any non-visual logic that can be exercised without a network call — pure functions, composables with mockable inputs, transform utilities, validators — ships with a test in `tests/` and is wired into `npm test`. UI rendering, real network behavior, and browser-API-dependent code are out of scope.
- **Big new features:** write the tests in the same change. Don't defer.
- **Modifying a tested feature:** check the related tests; update them in the same change if behavior shifts.
- **Tests must pass locally before you hand off.** If `npm run check` is red, don't ask the user to review.
## Security & Boundaries
The backend enforces access control and timeouts through shared middleware rather than per-handler code. Full details live in @api/AGENTS.md; the rules that matter at the project level:
- `requireReferer` is mounted globally on `/api/*` — handlers must not repeat the referer check.
- `requireValidIP()` is attached per-route where `?ip` is a required param — handlers must not repeat the IP check.
- Every upstream HTTP call goes through `fetchUpstream` (`common/fetch-with-timeout.js`) with an 8s timeout. Never add a bare `fetch()` to an `api/` handler.
## Workflow
- **Do not commit without explicit user approval.** The flow is: AI edits → user reviews → user says "commit" → AI commits. Silent commits are a breach of trust.
- **Commit message style** follows recent `git log` — `refactor(xxx): …` / `fix(ui): …` / `feat(ui): …` / `style: …` / `chore: …` prefix, body can be Chinese.
- **Every commit ends with** the Co-Authored-By trailer:
```
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
```
- **Do not commit without explicit user approval.** The flow is: AI edits → user reviews → user tests → user says "commit" → AI commits. Silent commits are a breach of trust.
- **One concern per commit.** Don't mix unrelated changes into a single commit. Split at the right seam.
- **Self-test before handing off.** Run `npm run check` (or at least `npm test`) for every change. If the change is visual (UI layout, styling, interactions) and can't be verified headless, say so explicitly so the user can test it in `npm run dev`.
- **Every commit is gated on user testing.** Even with tests green, a visual change needs the user to have looked at it before it lands.
- **Add yourself as a co-author to the commit.** If you are an AI.
- **Commit message style** follows recent `git log` — `Refactor(xxx): …` / `Fix(ui): …` / `Feat(xxx): …` / `Style: …` / `Chore: …` prefix.
- **On every commit, scan AGENTS.md (root + relevant sub-file) for staleness** — if the change adds a convention, renames a shared module, flips a rule, or invalidates an example, update the doc in the same commit. AGENTS.md drifting from reality is the main failure mode of this kind of document.
+6 -11
View File
@@ -1,16 +1,11 @@
# CLAUDE.md
> This file is automatically read by Claude Code at session start.
Authoritative project instructions: @AGENTS.md
**Read [AGENTS.md](./AGENTS.md) for everything.**
Claude-specific additions below; on conflict, AGENTS.md wins.
It is the single source of truth for this project:
## Claude-specific
- Tech stack (what's in / what's out — no Bootstrap, no flag-icons, no TypeScript)
- Directory layout
- UI infrastructure: shadcn-vue primitives in `frontend/components/ui/`, design tokens, semantic color system
- Code conventions: JavaScript only, Composition API, `variant="action" + <Spinner />` for trigger buttons, `<InputGroup>` for input + button combos, semantic tokens over `dark:` variants, etc.
- Commit discipline (do not commit without explicit user approval)
- "Do not" list
Nothing project-specific belongs here — put it in AGENTS.md so Codex / any other agent reading AGENTS.md stays in sync.
None at the moment. This file exists so Claude Code auto-loads AGENTS.md at
session start; additional Claude-only guidance goes here if it ever diverges
from what all contributors should follow.
+85
View File
@@ -0,0 +1,85 @@
# api/AGENTS.md
Conventions for Express 5 back-end handlers under `api/` and the shared back-end
code under `common/`. See ../AGENTS.md for universal project rules
(language, commits, testing expectations).
## Overview
The Express app is defined in `backend-server.js` at the repo root. Every route
is wired there and delegated to a handler module under `api/`. Shared code
(referer check, IP validator, fetch helper, Express middleware, MaxMind service)
lives under `common/` and is also consumed by the frontend where it makes sense
(e.g. `valid-ip.js`, `fetch-with-timeout.js`).
## Project layout
```
api/
├── configs.js ← /api/configs — reports which env-gated features are on
├── google-map.js ← /api/map — Google Static Maps image proxy (binary stream)
├── ipinfo-io.js, ipapi-com.js, ipapi-is.js, ip2location-io.js, ip-sb.js,
│ ipcheck-ing.js, maxmind.js ← IP geolocation source handlers (route per source)
├── invisibility-test.js ← /api/invisibility — proxy to private IPCheck.ing endpoint
├── mac-checker.js ← /api/macchecker — MAC vendor lookup
├── get-whois.js ← /api/whois — whoiser wrapper
├── cf-radar.js ← /api/cfradar — ASN details via Cloudflare Radar
├── dns-resolver.js ← /api/dnsresolver — DNS + DoH parallel query
├── get-user-info.js ← /api/getuserinfo — user-profile proxy
└── update-user-achievement.js ← /api/updateuserachievement — user-achievement proxy
common/
├── fetch-with-timeout.js ← fetchWithTimeout (5s default) + fetchUpstream (8s preset)
├── guards.js ← requireReferer + requireValidIP Express middleware
├── referer-check.js ← low-level referer allow-list check
├── valid-ip.js ← IPv4 / IPv6 validator (also re-exported from frontend)
├── maxmind-service.js ← mmdb reader + lookup
├── maxmind-updater.js ← scheduled mmdb auto-update
└── maxmind-db/ ← GeoLite2-ASN.mmdb + GeoLite2-City.mmdb
```
## Conventions
- **Handler shape.** Each handler is a single default export, `async (req, res) => …`. It reads from `req.query` / `req.body`, calls upstream, and writes one response.
- **Every upstream HTTP call uses `fetchUpstream`** from `common/fetch-with-timeout.js`:
```js
import { fetchUpstream } from '../common/fetch-with-timeout.js';
const apiRes = await fetchUpstream(url);
const json = await apiRes.json();
res.json(transform(json));
```
Default timeout is 8s. Never add a bare `fetch()` or `https.get()` — if a provider hangs, the Express connection should time out, not pin indefinitely.
- **Error shape.** `res.status(500).json({ error: error.message })` on upstream failures; `res.status(400).json({ error: '…' })` on bad input. Be terse — the frontend doesn't display these error strings verbatim.
- **Response shape.** IP-geolocation handlers normalize their upstream's response into the canonical shape consumed by the frontend (`ip` / `country` / `country_name` / `country_code` / `latitude` / `longitude` / `asn` / `org` / …). If you add a new source, match the existing shape.
## Security & Boundaries
### Guards live in middleware, not in handlers
- `requireReferer` is mounted globally on `/api/*` in `backend-server.js`. It rejects any request whose `Referer` header isn't on the `ALLOWED_DOMAINS` list (plus `localhost` always). **Handlers must not repeat the referer check.**
- `requireValidIP()` is attached per-route to every handler that takes `?ip=`. It rejects missing or malformed IPs before the handler runs. **Handlers must not repeat the IP check** — inside the handler body, `req.query.ip` is already known to be a well-formed string.
- If you add a new handler that needs a different-shape param guard, add the guard to `common/guards.js` and attach it in `backend-server.js` rather than open-coding the check in the handler.
### Private-API header pass-through (intentional exception)
Handlers that call our own private IPCheck.ing API (`ipcheck-ing.js`, `invisibility-test.js`, `update-user-achievement.js`, `get-user-info.js`) forward the caller's request headers to the upstream:
```js
const apiResponse = await fetchUpstream(url, { headers: { ...req.headers } });
```
This is deliberate — the upstream expects caller context (Accept-Language, auth tokens, etc.). Do **not** replicate this pattern for third-party upstreams; third-party handlers should send only what's explicitly needed.
### Defensive method gates
Some handlers keep a `req.method !== 'GET'` (or `PUT`) branch even though Express routes already gate the method. These exist because dedicated smoke tests assert on that branch directly against the handler. If you add or copy a handler, leave the defensive gate in place if a test covers it.
## Testing
- Smoke tests for every handler live in `tests/api-handlers.test.js`. They cover method gating, param presence / validity (beyond what middleware handles), and the "API key missing" early-return branches.
- Never hit real upstream APIs in tests — assert on branches that `return` before the first `fetchUpstream`.
- Middleware itself is covered by `tests/guards.test.js` (referer + IP validation). Don't duplicate those assertions at the handler level.
- `fetchUpstream` / `fetchWithTimeout` timing + abort behavior is covered by `tests/fetch-with-timeout.test.js`.
+153
View File
@@ -0,0 +1,153 @@
# frontend/AGENTS.md
Conventions and patterns specific to the Vue 3 SPA under `frontend/`.
See ../AGENTS.md for universal project rules (language, i18n, commits, testing expectations).
## Overview
Vue 3 + Composition API single-page app. State in Pinia, routing via vue-router hash mode, styling via Tailwind CSS v4 on top of shadcn-vue primitives (copied in, not imported from a package). No TypeScript, no Bootstrap, no `dark:` dual-pair utilities.
## Project layout
```
frontend/
├── App.vue ← thin shell, delegates to composables
├── main.js ← app bootstrap + global init
├── store.js ← Pinia main store
├── firebase-init.js ← Firebase Auth env-gated init
├── sw.js ← Serwist service worker
├── router/ ← Advanced Tools subroutes (open inside a Drawer)
├── locales/ ← i18n copy (en / zh / fr / tr) + security-checklist data
├── style/style.css ← Tailwind v4 entry + design tokens
├── lib/ ← cn() helper (tailwind-merge + clsx)
├── data/ ← static config
│ (achievements / ip-databases / sections /
│ default-preferences / changelog)
├── utils/ ← pure helpers
│ (valid-ip / getips / transform-ip-data /
│ hero-ip-size / fetch-with-timeout / …)
├── composables/ ← reusable composition logic
│ ├── use-info-mask.js
│ ├── use-refresh-orchestrator.js
│ ├── use-scroll-to.js
│ ├── use-section-tracking.js
│ ├── use-shortcuts.js
│ └── use-status-tone.js ← shared 4-tier business-state → visual-color mapping
└── components/
├── *.vue ← top-level sections (IpInfos / Connectivity / WebRTC / DnsLeaks
│ / SpeedTest / Advanced / Footer / Nav / Achievements / User /
│ Additional)
├── ip-infos/ ← IP-card subcomponents (IPCard / IpDetailPanel / ASNInfo / DataPairBar)
├── advanced-tools/ ← 10 tool subpages opened inside the bottom Drawer
│ (MtrTest / GlobalLatencyTest / RuleTest / DnsResolver /
│ CensorshipCheck / Whois / MacChecker / BrowserInfo /
│ InvisibilityTest / SecurityChecklist + Empty)
├── widgets/ ← small reusables (QueryIP / Help / Preferences / InfoMask / PWA / Toast)
├── svgicons/ ← a few inline SVGs
└── ui/ ← shadcn-vue copy-in primitives (see "UI system" below)
```
## Conventions
- **Composition API.** All components use `<script setup>`; no Options API.
- **Path alias.** `@` → `frontend/` (defined in `jsconfig.json` and `vite.config.js`). Use `@/components/...`, `@/utils/...`, `@/composables/...`.
- **Shared-with-backend helpers live under `common/`.** Import them with a relative path, e.g. `../../common/valid-ip.js`. `common/valid-ip.js` is re-exported from `frontend/utils/valid-ip.js` so most consumers can keep using `@/utils/valid-ip.js`; follow that pattern when adding more shared helpers (see `frontend/utils/fetch-with-timeout.js` for the second example).
## shadcn-vue first
Before hand-rolling a UI component (button, dialog, popover, list, etc.):
1. **Check `frontend/components/ui/` first** — 21 primitives are already copied in (see below). Missing variants are almost never a reason to bypass a primitive; `:class` overrides, `as-child`, and tw-merge cover nearly every state-color need.
2. **If no local primitive fits, check https://www.shadcn-vue.com/docs/components** — shadcn-vue covers a lot more than what's in the repo. Copy one in if it's a good fit.
3. **Only fall back to hand-rolled Tailwind** when the shape or behavior genuinely doesn't exist in shadcn-vue.
## UI system
### Primitives
Located at `frontend/components/ui/`. 21 primitives copied in:
`accordion` · `badge` · `button` · `button-group` · `card` · `collapsible` · `dialog` (with `DialogHeader`) · `drawer` (vaul-vue) · `dropdown-menu` · `input` · `input-group` · `progress` · `select` · `separator` · `sheet` · `sonner` · `spinner` · `switch` · `tabs` · `toggle-group` · `tooltip`
Three are project-specific, not in stock shadcn-vue:
- **`ButtonGroup`** — visual container for stitching buttons together.
- **`InputGroup`** — flattens `Input + Button` (or `Input + Select`) into a single bordered control via `[&>input] / [&>button]` passthrough selectors. The group itself carries border / ring / shadow. Use this whenever an input is paired with a trigger button — never hand-roll `rounded-r-none / -ml-px`.
- **`Spinner`** — lucide `Loader2` + `animate-spin` + `role="status"`.
### Design tokens
Tokens live at the top of `frontend/style/style.css`. On top of shadcn defaults, four business-semantic colors are defined:
| Token | Meaning | Light | Dark |
|---|---|---|---|
| `--info` | waiting / in-progress | sky-500 | sky-400 |
| `--success` | ok-fast / success | green-500 | green-400 |
| `--warning` | ok-slow / warning | amber-500 | amber-400 |
| `--action` | the "run / trigger" brand color | blue-500 | blue-500 |
`--destructive` uses the shadcn default. Every token has a paired `-foreground`; `bg-*` consumers do not need `dark:` variants.
Rule: use semantic tokens (`bg-info` / `bg-success` / `bg-warning` / `bg-destructive` / `bg-action` / `bg-muted` / `bg-card` / `bg-accent` / `text-foreground` / `text-muted-foreground` / `border` / …). Do not write `dark:` dual pairs like `bg-neutral-50 dark:bg-neutral-900` — that pattern does not exist in this codebase.
### Button and Badge variants
Button adds `action` (blue-500 trigger color) to the shadcn defaults (`default / destructive / outline / secondary / ghost / link`).
Badge adds `success` to the defaults. Badge hover is globally disabled — Badge is a display element, not an interactive one. Put `hover:bg-muted` etc. on an outer `<a>` / `<button>` when interactivity is needed.
### Status tones
All "business state → visual color" mapping goes through `frontend/composables/use-status-tone.js`. It exposes four tones: `wait` / `ok-fast` / `ok-slow` / `fail`.
Rule: any new module that surfaces a status reuses these tones. Do not hand-write `switch` statements that map states to hex codes or Tailwind classes.
### Canonical patterns
**Trigger button** — `variant="action"` + `<Spinner />` + `:disabled` is the three-piece pattern used by QueryIP, MacChecker, DnsResolver, Whois, MtrTest, Ping, Censorship, Invisibility, etc.
```vue
<Button variant="action" :disabled="isRunning" @click="run">
<Spinner v-if="isRunning" />
Run
</Button>
```
**Input with trigger** — `InputGroup` flattens the pair. `SelectTrigger` renders as a button, so `Select` can swap in for `Input` with no extra work.
```vue
<InputGroup>
<Input v-model="q" placeholder="..." />
<Button variant="action" :disabled="running" @click="run">
<Spinner v-if="running" /> Run
</Button>
</InputGroup>
```
**Status card** — homepage status cards (Connectivity / WebRTC / DnsLeak / IPCard / RuleTest) use:
```vue
<Card class="keyboard-shortcut-card jn-card transition-transform duration-300 ease-out hover:-translate-y-1.5">
```
`jn-card` is the project marker for shadow / border / keyboard-nav outline. `keyboard-shortcut-card` is the marker that the J/K keyboard-shortcut navigation queries for.
**Flag** — always use the iconify circle-flags set:
```vue
<Icon :icon="'circle-flags:' + code.toLowerCase()" class="size-4" />
```
**Tables vs lists** — two or three columns with independent header semantics → `<table>` with `thead text-xs uppercase tracking-wide text-muted-foreground` + `tbody divide-y` + row `hover:bg-muted/50`. Mobile-friendly rows or asymmetric content with no header meaning → `<ul class="rounded-lg border bg-card divide-y">` + `<li>`.
**Dialog header** — use the `<DialogHeader :icon="SomeIcon" :title="..." />` primitive to get an icon, title, and DialogClose in one element, matching the Sheet / Drawer header treatment.
**Drawer** — reserved for the bottom drawer (Advanced Tools panel), via `vaul-vue`. Side panels (right / left) use `Sheet`. The Drawer defaults to `handleOnly: true` (only the top handle is draggable) and CSS is set so body text remains selectable.
**Motion** — card hover lift: `transition-transform duration-300 ease-out hover:-translate-y-1.5`. Panel fade-slide: scoped `.fade-slide-*` transitions. Loading: `<Spinner />`, not hand-rolled `animate-pulse` dot clusters.
## Testing
- Composables and utils are the main target — covered by `tests/composable-*.test.js` and the various utility tests.
- Vue rendering / user interactions / browser APIs: out of scope for the Node test runner.
- For visual changes (layout, styling, animations), self-testing is not possible — explicitly ask the user to verify via `npm run dev`.