A pass over every file the branch touched, after the iOS animation hunt left tries and reverts behind it. Removed what nothing reaches: the view-transition rule for a transition no code starts, an unreachable guard in the transition hook, a no-op KeepAlive key branch, a sign-in guard the menu already enforces, usage-dialog wrappers that only forwarded to the store, a duplicated modifier-click check and card filter (isPlainClick / listedTools), a test that hand-rolled listedTools, a drawer-era fixture parameter. auth-flow.js folded into auth-redirect.js. Comments cut to what the code cannot say itself — file headers of a few lines, the measured iOS reasons, non-obvious orderings — and stale ones fixed (Nav under KeepAlive, Home.vue reusing DEFAULT_META, "from Nav", enabledCards). AGENTS.md sections tightened to the code as it is now. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_013GbLTGMZ45MV78qd2tHoco
11 KiB
frontend/AGENTS.md
Conventions specific to the Vue 3 SPA under frontend/. Universal rules
(language, i18n, commits, testing) live in ../AGENTS.md.
Overview
Vue 3 <script setup> + Pinia + vue-router (HTML5 history) + Tailwind CSS v4
over copied-in shadcn-vue primitives. No TypeScript, no dark: dual pairs.
Layout
frontend/
├── App.vue / main.js / store.js / router/ / locales/ / style/style.css
├── firebase-init.js ← env-gated lazy Firebase Auth (boot path: utils/auth-hint.js;
│ PWA / blocked-popup redirect sign-in: utils/auth-redirect.js;
│ the PWA's own auth domain: utils/auth-domain.js)
├── sentry-init.js ← env-gated Sentry (see "Error monitoring")
├── data/ ← static config (tools registry drives pages+cards+nav)
├── lib/ · utils/ · composables/ ← see "Helper placement"
└── components/ ← sections + ip-infos/ advanced-tools/ report/ widgets/ svgicons/ ui/
Every file opens with a header comment stating its purpose — read those.
Conventions
- Composition API everywhere; no Options API. Alias
@→frontend/. - Shared-with-backend helpers live in
common/, re-exported through a thinutils/bridge so consumers keep@/utils/...imports (utils/ip/valid-ip.js). - Helper placement: Vue reactivity / lifecycle →
composables/(useXxx); otherwiseutils/(neveruse-prefixed).lib/stays shadcn-only. A pure function next to a composable exports from that composable's file. - Inside
utils/: app-wide plumbing at its root; IP / ASN data shaping and validation →ip/; the report pipeline →report/; the pure layer of one feature (a tool, a section, Pulse) →features/; per-source probe modules keep their own dirs (getips/,dnsleaks/,persona/).
Achievements are event-driven
Components never touch achievements — they emit domain events unconditionally
(emitAppEvent('speedtest:finished', {…})); data/achievement-rules.js maps
events → slugs, composables/use-achievement-engine.js owns all guards (rules
wait for the remote snapshot; pre-sync hits parked). New achievement = entry +
rule + (only if no suitable event exists) a new event. The shareable report
rides the same bus: <domain>:finished → use-report-collector.js →
utils/report/report-builders.js → sections whitelisted by common/report-schema.js.
New reportable test = event + builder + schema entry in one change; new result
semantics = builder whitelist + schema enum too (builders fail soft, fixtures
are frozen: drift = quietly missing fields). Report links are public: the
builder, not the renderer, drops visitor-supplied data (Persona Check: id / axis
/ verdict, never detail; Invisibility: key + flag).
Commands are the imperative twin of events
utils/app-commands.js: events say "this happened"; a command says "do this" —
one owner (use-app-command.js, scope-bound, setup-time), and
dispatchAppCommand resolves with its result. Payload = one plain JSON object
shaped at registration; rejections are appCommandError(code, message) with
reserved auth / quota / input (the bus adds unavailable / timeout).
Cross-component triggers use the bus, never template refs (refs stay for UI
chrome); tools register at setup, callers waitForAppCommand first.
Advanced Tools are pages, kept alive
- Every tool is a page at
/tools/:slug(ToolPage.vueoverdata/tools.js), and every opener routes there: cards are real<a href>(a plain clickrouter.pushes, modified clicks stay the browser's), Nav, shortcuts (Advanced.vue's exposedopenTool), in-app links./?tool=<slug>is only a replace-redirect in the router'sbeforeEach(utils/legacy-tool-link.js). - Gates (
requiresOriginalSite,requiresConfig: '<configs key>', e.g.asn→cloudFlare) decide listing only, viaisToolAvailable()/listedTools()(card grid, Nav); a/tools/:sluglink is never gated. App.vuekeeps Home andToolPagealive (KeepAlive);ToolPagekeeps up to 8 tools alive per slug, dropped on sign-out / account switch (utils/tool-cache.js). Back / forward is plain history; the breadcrumb's Home steps back when the previous entry is Home (utils/back-target.js), so Home returns as left, scroll included.- A cached page never unmounts, so whatever acts on the route, the document
head, window events or the store's one-shot triggers gates on
use-route-active.js(useRouteActive/useActiveValue/useActiveEventListener), not on mount / unmount. Overlays on a page being left are closed byuse-overlay-shortcuts.js— their portal would outlive it; App.vue's own (storeopenSheet, theUserdialog) close on a path change. - Homepage state reaches a tool page through the store, the app-events
collectors and the command bus: a cached Home keeps its commands registered,
so a caller dispatches in place and goes home first only when
hasAppCommand()says Home never mounted (Persona Check).
ASN Profile
- One
/api/asn-profilerequest, one loading state, then the whole page; a section (reputation included — noconfigs.originalSitecheck) renders only when itsstatusisok.ASNConnectivity.vuegets:expandable="false" :bordered="false"; its defaults keep IPCard / QueryIP unchanged. peeringdbis operator self-reporting: vocabularies → locale keys (PEERING_ENUMS), names / cities as written.ASN_PROFILE_TIMEOUT_MSstays above the largest backend section deadline (tested).- ASN Info's footer links
/tools/asn?q=AS<n>(hidden when gated off); in QueryIP it emitsopen-toolvia IpDetailPanel so the dialog closes first.
Overlays take no keyboard shortcuts
One dispatcher (utils/shortcut.js) over the map use-shortcuts.js registers
(home route only; Home clears it when left; registerShortcuts() replaces it),
suspended while an overlay is open — keyed off form: the ui/ roots Dialog /
Sheet / Drawer call use-overlay-shortcuts.js, so anything built on them
inherits it; overlays nest; Esc and native scrolling still work.
Error monitoring (Sentry) is env-gated and invisible to app code
No VITE_SENTRY_DSN_FRONTEND, or a DEV build → no Sentry loaded (gated dynamic
import, like firebase-init.js; all config in sentry-init.js). Never import
@sentry/vue in app code. Explicit signals go through the app-events bus:
ip-source:exhausted (a card's whole source chain failed), only when another
card resolved a valid IP of that version. Traps: console.error is captured,
fingerprinted on its first argument — name the failure there; utils/getips/
failures stay console.warn. Replay shows page text unmasked by design (input
masked; in the privacy policy). No backend 5xx capture; envelopes: /api/monitoring.
UI system
shadcn-vue first: components/ui/, then the shadcn-vue docs; hand-rolled
Tailwind only when neither fits. Keep on upstream syncs: Spinner +
ToolLoadingSkeleton, toggle / toggle-group's primary pressed pair, the
overlay roots' shortcut suspension, select's trigger geometry (py-1 + flex
value span, not -webkit-box — Safari shifts it ~2px).
Design tokens
style/style.css: --info (waiting) · --success (ok-fast) · --warning
(ok-slow) · --action (run), each with -foreground; semantic tokens only,
never dark: pairs. Button adds action / success; Badge adds success,
hover disabled (wrap it to interact). FAB colors: action trigger, default
stateless panel, success protective state on, secondary dock; ≤ 2 accents.
Status tones
Every "business state → color" mapping goes through use-status-tone.js
(wait / ok-fast / ok-slow / fail), normally via ipFieldTone().
Canonical patterns — copy the named exemplar, don't re-invent
- Trigger button —
variant="action"+<Spinner v-if />+:disabled(QueryIP, Whois). - Input + icon trigger — flex row, compact icon Button, no text label (QueryIP).
- AutoFill-proof inputs — all six on every free-form Input:
autocomplete/autocorrect/autocapitalize="off",spellcheck="false",data-1p-ignore,data-lpignore="true"; placeholders avoid "address / 地址 / adresse / adresi" (iOS QuickType keys on the word). - Status card —
keyboard-shortcut-card(J/K target)jn-card(shadow / border / outline) + hover lift (IPCard). - Flag — always
<Icon :icon="'circle-flags:' + code.toLowerCase()" />. - Dates & times — via
utils/time-utils.js+ the vue-i18n locale; no hand-rolledIntl/toLocaleDateStringwithout a why-comment. - Fit-to-width tokens — IP / MAC strings in
<FitText>(HERO_TIERS/INLINE_TIERS;:max-lines="2"on heroes); no length-threshold helpers. - Filter tags —
ToggleGroup :spacing="2"of detachedh-7 rounded-fullpills,w-full flex-wrap(IPHistory, DnsResolver); neverspacing=0. - Shareable tool input — watch
useActiveValue(() => route.query.q, { pathOf: () => route.path })(immediate, so mount, later changes and aqchanged while cached all run; another page'sqnever does),router.replaceit on every run (AsnProfile); the URL is/tools/<slug>?q=. - Fixed option sets — a closed list wider than one line is a
Select. - Qualifier + input + run —
Select+Inputin oneButtonGroup, the run Button in a second (DnsResolver); triggerw-auto shrink-0; never wraps. - Tables vs lists — real per-column header semantics →
<table>; otherwise a bordered<ul class="rounded-lg border bg-card divide-y">. - Dialog header — the
<DialogHeader :icon :title />primitive. - Site navigation — one
Nav.vueinApp.vueabove every route (fixed;index.htmlpads the body for it), never per page;User/Preferencessit beside it, one each. What a click does on the current route isresolveNavTarget()(utils/nav-target.js: scroll on Home, go home then scroll elsewhere, a tool → its page), carried out byuse-nav-target.js; no section is highlighted off Home. - Page breadcrumb — every page but Home opens its body with
<PageBreadcrumb :items />above the<h1>(← Home / … / current page): real links, Home viaresolveBackTarget(), section crumbs via the Nav's helper, the last crumbaria-current="page". - Page transitions — View Transitions hooked in the router's
beforeResolve(utils/page-transition.js: Home → page slides forward, → Home back, page → page fades; keyframes instyle/style.css); none on a query-only change, the first load, reduced motion or a history step the browser animated itself. Only the Nav has aview-transition-name(it stays still). After a history step,utils/revive-animations.jsreplays the page's running CSS animations: iOS swipe-back leaves them frozen. - Responsive hide —
.hiddenis!important(style/style.css), sohidden sm:flexnever shows: writemax-sm:hidden. - Drawer vs Sheet — bottom Drawer only for a full-bleed expansion of an
inline visual (ASNConnectivity's graph); side panels use
Sheet; a tool is a page, never an overlay. - Motion — hover lift
transition-transform duration-300 ease-out hover:-translate-y-1.5; loading is<Spinner />, never pulse-dots.
Testing
Composables and utils are the target (tests/composable-*.test.js); rendering /
browser APIs are out of scope — visual changes need the user in pnpm dev.