Chore: trim the branch — leftovers, redundancy, comments

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
This commit is contained in:
Claude committed 2026-10-05 16:50:16 +00:00
1 parent 47eca1a4f6
commit 96525d2fc7
62 files changed
+293 -625

No files matched your search

+8 -11
View File
@@ -38,17 +38,14 @@ VITE_PULSE_BEACON_URL=""
VITE_CURL_IPV4_DOMAIN="" VITE_CURL_IPV4_DOMAIN=""
VITE_CURL_IPV6_DOMAIN="" VITE_CURL_IPV6_DOMAIN=""
VITE_CURL_IPV64_DOMAIN="" VITE_CURL_IPV64_DOMAIN=""
# Firebase Auth — optional; sign-in is offered only when all three are set. # Firebase Auth — optional; sign-in is offered when API key, auth domain and
# VITE_FIREBASE_AUTH_DOMAIN is the login domain browsers use (it may be shared # project id are all set. VITE_FIREBASE_AUTH_DOMAIN is the browsers' login
# with other services, e.g. "auth.ipcheck.ing"); each OAuth provider's # domain (e.g. "auth.ipcheck.ing"); each OAuth provider's callback URL is
# callback URL is https://<that domain>/__/auth/handler # https://<that domain>/__/auth/handler.
# VITE_FIREBASE_PWA_AUTH_DOMAIN — optional, used only inside the installed PWA, # VITE_FIREBASE_PWA_AUTH_DOMAIN — optional, installed PWA only: the site's own
# typically the site's own host (e.g. "ipcheck.ing"). The PWA needs a # host (e.g. "ipcheck.ing"), served by the /__/auth proxy. Add it to Firebase
# same-origin auth handler: with VITE_FIREBASE_PROJECT_ID set, the frontend # Authentication → Authorized domains and https://<it>/__/auth/handler to
# and Vite dev servers proxy /__/auth/* to Firebase. List the host in Firebase # Google's OAuth client; GitHub (one callback) is then hidden in the PWA.
# Authentication → Authorized domains and add https://<site host>/__/auth/handler
# to Google's OAuth client. GitHub allows one callback, so GitHub sign-in is
# hidden in the PWA when this is set. Empty → the PWA uses the default too.
VITE_FIREBASE_API_KEY="" VITE_FIREBASE_API_KEY=""
VITE_FIREBASE_AUTH_DOMAIN="" VITE_FIREBASE_AUTH_DOMAIN=""
VITE_FIREBASE_PWA_AUTH_DOMAIN="" VITE_FIREBASE_PWA_AUTH_DOMAIN=""
+8 -8
View File
@@ -160,14 +160,14 @@ upstreams; those get only what's explicitly needed.
### Firebase Auth handler proxy ### Firebase Auth handler proxy
`frontend-server.js` (production) and `vite.config.js` (dev) reverse-proxy `frontend-server.js` (production) and `vite.config.js` (dev) reverse-proxy
`/__/auth/*` to `<projectId>.firebaseapp.com` (target from `/__/auth/*` to `<projectId>.firebaseapp.com` (`common/firebase-auth-proxy.js`)
`common/firebase-auth-proxy.js`), mounted only when `VITE_FIREBASE_PROJECT_ID` when `VITE_FIREBASE_PROJECT_ID` is set. `VITE_FIREBASE_PWA_AUTH_DOMAIN` points
is set. `VITE_FIREBASE_PWA_AUTH_DOMAIN` points the installed PWA at the site's the installed PWA at the site's own host, so its sign-in handler runs
own host, so its sign-in handler runs first-party — inside the PWA's webview first-party — inside the PWA's webview and Safari's storage partition — and
and Safari's storage partition — and `getRedirectResult` finds the result; `getRedirectResult` finds the result; browsers keep the shared
browsers keep the shared `VITE_FIREBASE_AUTH_DOMAIN` (GitHub's single callback `VITE_FIREBASE_AUTH_DOMAIN` (GitHub's single callback lives there, so the PWA
lives there, so the PWA then offers Google only). It is not an `/api` route: no then offers Google only). Not an `/api` route: no guards, caching or
guards, no caching, nothing logged per request. per-request logs.
## Edge caching ## Edge caching
+4 -11
View File
@@ -1,17 +1,10 @@
// Firebase Auth handler proxy target. // Firebase Auth handler proxy: `/__/auth/*` on the site's own origin →
// // `<projectId>.firebaseapp.com`, mounted by frontend-server.js (production)
// Firebase's redirect sign-in runs its handler page (`/__/auth/handler`, plus // and vite.config.js (dev). Why: api/AGENTS.md "Firebase Auth handler proxy".
// the `iframe` / `*.js` files it loads) on `authDomain`. Serving those paths
// from the site's own origin — a reverse proxy to the project's
// `<projectId>.firebaseapp.com` host — keeps the handler first-party, so the
// sign-in result lands in the same storage the app reads it back from (the
// installed iOS PWA and Safari's storage partitioning otherwise lose it).
// Mounted by frontend-server.js (production) and vite.config.js (dev).
export const FIREBASE_AUTH_PROXY_PATH = '/__/auth'; export const FIREBASE_AUTH_PROXY_PATH = '/__/auth';
// Origin the proxy forwards to, or null when no project is configured // null without a project id: no Firebase, no proxy.
// (no Firebase → no proxy).
export const firebaseAuthProxyTarget = (projectId) => { export const firebaseAuthProxyTarget = (projectId) => {
const id = typeof projectId === 'string' ? projectId.trim() : ''; const id = typeof projectId === 'string' ? projectId.trim() : '';
return id ? `https://${id}.firebaseapp.com` : null; return id ? `https://${id}.firebaseapp.com` : null;
+3 -8
View File
@@ -22,14 +22,9 @@ frontendApp.use('/api', createProxyMiddleware({
changeOrigin: true changeOrigin: true
})); }));
// Firebase Auth handler on our own origin. `/__/auth/*` (handler, iframe and // Firebase's auth handler served first-party (api/AGENTS.md "Firebase Auth
// their scripts) is reverse-proxied to <projectId>.firebaseapp.com so the // handler proxy"); ahead of the static layer and SPA fallback so it never
// sign-in handler runs first-party — inside the installed PWA's own webview // resolves to index.html.
// and within Safari's storage partition, where getRedirectResult can read the
// result back. Switched on by VITE_FIREBASE_PROJECT_ID; the PWA uses it when
// VITE_FIREBASE_PWA_AUTH_DOMAIN is this site's own host. Mounted ahead of the
// static layer and the SPA fallback so the handler page never resolves to
// index.html.
const firebaseAuthTarget = firebaseAuthProxyTarget(process.env.VITE_FIREBASE_PROJECT_ID); const firebaseAuthTarget = firebaseAuthProxyTarget(process.env.VITE_FIREBASE_PROJECT_ID);
if (firebaseAuthTarget) { if (firebaseAuthTarget) {
frontendApp.use(FIREBASE_AUTH_PROXY_PATH, createProxyMiddleware({ frontendApp.use(FIREBASE_AUTH_PROXY_PATH, createProxyMiddleware({
+22 -31
View File
@@ -13,13 +13,9 @@ over copied-in shadcn-vue primitives. No TypeScript, no `dark:` dual pairs.
``` ```
frontend/ frontend/
├── App.vue / main.js / store.js / router/ / locales/ / style/style.css ├── App.vue / main.js / store.js / router/ / locales/ / style/style.css
│ (App.vue: the site Nav + User / Preferences, then the page) ├── firebase-init.js ← env-gated lazy Firebase Auth (boot path: utils/auth-hint.js;
├── firebase-init.js ← env-gated lazy Firebase Auth (boot path: utils/auth-hint.js) │ PWA / blocked-popup redirect sign-in: utils/auth-redirect.js;
│ sign-in: popup in a browser tab, redirect in the installed │ the PWA's own auth domain: utils/auth-domain.js)
│ PWA or on a blocked popup (utils/auth-flow.js); the return
│ is consumed at boot via a sessionStorage marker
│ (utils/auth-redirect.js); the PWA may use its own auth
│ domain, then Google only (utils/auth-domain.js)
├── sentry-init.js ← env-gated Sentry (see "Error monitoring") ├── sentry-init.js ← env-gated Sentry (see "Error monitoring")
├── data/ ← static config (tools registry drives pages+cards+nav) ├── data/ ← static config (tools registry drives pages+cards+nav)
├── lib/ · utils/ · composables/ ← see "Helper placement" ├── lib/ · utils/ · composables/ ← see "Helper placement"
@@ -68,13 +64,11 @@ chrome); tools register at setup, callers `waitForAppCommand` first.
### Advanced Tools are pages, kept alive ### Advanced Tools are pages, kept alive
- Every tool is a page at `/tools/:slug` (`ToolPage.vue` over the - Every tool is a page at `/tools/:slug` (`ToolPage.vue` over `data/tools.js`),
`data/tools.js` registry), and every opener routes there: cards are real and every opener routes there: cards are real `<a href>` (a plain click
`<a href>` (a plain click `router.push`es, modifier clicks stay the `router.push`es, modified clicks stay the browser's), Nav, shortcuts
browser's), Nav, shortcuts (`Advanced.vue`'s exposed (`Advanced.vue`'s exposed `openTool`), in-app links. `/?tool=<slug>` is only
`openTool`), in-app links. `/?tool=<slug>` survives only as a replace-redirect a replace-redirect in the router's `beforeEach` (`utils/legacy-tool-link.js`).
in the router's `beforeEach` (`utils/legacy-tool-link.js`). No tool opens as
an overlay.
- Gates (`requiresOriginalSite`, `requiresConfig: '<configs key>'`, e.g. `asn` → - Gates (`requiresOriginalSite`, `requiresConfig: '<configs key>'`, e.g. `asn` →
`cloudFlare`) decide listing only, via `isToolAvailable()` / `listedTools()` `cloudFlare`) decide listing only, via `isToolAvailable()` / `listedTools()`
(card grid, Nav); a `/tools/:slug` link is never gated. (card grid, Nav); a `/tools/:slug` link is never gated.
@@ -166,8 +160,7 @@ Every "business state → color" mapping goes through `use-status-tone.js`
- **Shareable tool input** — watch `useActiveValue(() => route.query.q, { - **Shareable tool input** — watch `useActiveValue(() => route.query.q, {
pathOf: () => route.path })` (immediate, so mount, later changes and a `q` pathOf: () => route.path })` (immediate, so mount, later changes and a `q`
changed while cached all run; another page's `q` never does), `router.replace` changed while cached all run; another page's `q` never does), `router.replace`
it on every run (AsnProfile); the URL is `/tools/<slug>?q=`, which a legacy it on every run (AsnProfile); the URL is `/tools/<slug>?q=`.
`/?tool=<slug>&q=` redirects to with `q` kept.
- **Fixed option sets** — a closed list wider than one line is a `Select`. - **Fixed option sets** — a closed list wider than one line is a `Select`.
- **Qualifier + input + run** — `Select` + `Input` in one `ButtonGroup`, the run - **Qualifier + input + run** — `Select` + `Input` in one `ButtonGroup`, the run
Button in a second (DnsResolver); trigger `w-auto shrink-0`; never wraps. Button in a second (DnsResolver); trigger `w-auto shrink-0`; never wraps.
@@ -176,28 +169,26 @@ Every "business state → color" mapping goes through `use-status-tone.js`
- **Dialog header** — the `<DialogHeader :icon :title />` primitive. - **Dialog header** — the `<DialogHeader :icon :title />` primitive.
- **Site navigation** — one `Nav.vue` in `App.vue` above every route (fixed; - **Site navigation** — one `Nav.vue` in `App.vue` above every route (fixed;
`index.html` pads the body for it), never per page; `User` / `Preferences` `index.html` pads the body for it), never per page; `User` / `Preferences`
sit beside it, one each. Its sections and tools are peers: what a click sit beside it, one each. What a click does on the current route is
does on the current route is `resolveNavTarget()` (`utils/nav-target.js`: `resolveNavTarget()` (`utils/nav-target.js`: scroll on Home, go home then
scroll on Home, go home then scroll elsewhere, a tool → its page), carried scroll elsewhere, a tool → its page), carried out by `use-nav-target.js`;
out by `use-nav-target.js`; no section is highlighted off Home. no section is highlighted off Home.
- **Page breadcrumb** — every page but Home opens its body with - **Page breadcrumb** — every page but Home opens its body with
`<PageBreadcrumb :items />` above the `<h1>` (← Home / … / current page): `<PageBreadcrumb :items />` above the `<h1>` (← Home / … / current page):
real links, Home via `resolveBackTarget()`, section crumbs via the Nav's real links, Home via `resolveBackTarget()`, section crumbs via the Nav's
helper, the last crumb `aria-current="page"`. helper, the last crumb `aria-current="page"`.
- **Page transitions** — View Transitions keyed by the direction - **Page transitions** — View Transitions hooked in the router's
`utils/page-transition.js` resolves (Home → page slides forward, → Home `beforeResolve` (`utils/page-transition.js`: Home → page slides forward,
back, page → page fades; keyframes in `style/style.css`), hooked in the → Home back, page → page fades; keyframes in `style/style.css`); none on a
router's `beforeResolve`; none on a query-only change, the first load, or query-only change, the first load, reduced motion or a history step the
under reduced motion. The Nav opts out via browser animated itself. Only the Nav has a `view-transition-name` (it
`view-transition-name`; nothing else gets one. After a history step the stays still). After a history step, `utils/revive-animations.js` replays
page's running CSS animations are cancelled, replayed and set back to the page's running CSS animations: iOS swipe-back leaves them frozen.
their time (`utils/revive-animations.js`): on iOS the swipe-back gesture
leaves them visually frozen until their layer is rebuilt.
- **Responsive hide** — `.hidden` is `!important` (`style/style.css`), so - **Responsive hide** — `.hidden` is `!important` (`style/style.css`), so
`hidden sm:flex` never shows: write `max-sm:hidden`. `hidden sm:flex` never shows: write `max-sm:hidden`.
- **Drawer vs Sheet** — bottom Drawer only for a full-bleed expansion of an - **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 inline visual (ASNConnectivity's graph); side panels use `Sheet`; a tool is
page, never either. a page, never an overlay.
- **Motion** — hover lift `transition-transform duration-300 ease-out - **Motion** — hover lift `transition-transform duration-300 ease-out
hover:-translate-y-1.5`; loading is `<Spinner />`, never pulse-dots. hover:-translate-y-1.5`; loading is `<Spinner />`, never pulse-dots.
+9 -16
View File
@@ -1,21 +1,17 @@
<template> <template>
<!-- Thin app shell: only the globals that must exist on every route live <!-- Thin app shell: the globals every route shares (Nav, account and
here (the site Nav, the account and preferences dialogs, tooltip context, preferences dialogs, tooltip context, toast host, PWA prompt, theme).
toast host, PWA install prompt, theme). Pages swap in via <router-view> Home and ToolPage stay alive, so coming back neither re-runs the tests nor
below the fixed Nav (index.html pads the body for it); the homepage and loses a tool's state; keyed by route name, every tool route shares one
the tool page stay alive in the KeepAlive (never evicted), so leaving and ToolPage, which caches each tool per slug itself. -->
coming back neither re-runs the homepage tests nor loses a tool's state. Every tool route shares the one
ToolPage instance (key 'tool'), which caches each tool per slug itself.
Cached pages pause while hidden — see composables/use-route-active.js. -->
<TooltipProvider :delay-duration="150"> <TooltipProvider :delay-duration="150">
<NavBar /> <NavBar />
<router-view v-slot="{ Component, route: viewRoute }"> <router-view v-slot="{ Component, route: viewRoute }">
<KeepAlive :include="['Home', 'ToolPage']"> <KeepAlive :include="['Home', 'ToolPage']">
<component :is="Component" :key="viewRoute.name === 'tool' ? 'tool' : viewRoute.name" /> <component :is="Component" :key="viewRoute.name" />
</KeepAlive> </KeepAlive>
</router-view> </router-view>
<!-- Dialog hosts for the Nav's account menu and the sign-in tools' <!-- Dialog hosts for the Nav's account menu and the tools' quota hints. -->
quota hints: Benefits & Usage, Preferences. One each, site-wide. -->
<User /> <User />
<Preferences /> <Preferences />
<Alert /> <Alert />
@@ -57,8 +53,7 @@ import { useAchievementEngine } from '@/composables/use-achievement-engine.js';
import { useReportCollector } from '@/composables/use-report-collector.js'; import { useReportCollector } from '@/composables/use-report-collector.js';
import { useAppPersonaCollector } from '@/composables/use-persona-collector.js'; import { useAppPersonaCollector } from '@/composables/use-persona-collector.js';
// A page change closes the sheet held in store.openSheet (preferences, Earth // A page change closes store.openSheet: a site-wide sheet would stay open over
// Online, the nav menu, …): the site-wide ones would otherwise stay open over
// the next page, and one a left page hosted would reopen when it returns. // the next page, and one a left page hosted would reopen when it returns.
const store = useMainStore(); const store = useMainStore();
const route = useRoute(); const route = useRoute();
@@ -69,9 +64,7 @@ watch(() => route.path, () => {
// Pre-Vue boot overlay → real app hand-off. CSS lives in index.html. // Pre-Vue boot overlay → real app hand-off. CSS lives in index.html.
// #app is revealed IMMEDIATELY at mount: it fades in underneath the opaque // #app is revealed IMMEDIATELY at mount: it fades in underneath the opaque
// overlay while the overlay plays its exit (text fade → logo shrink → // overlay while the overlay plays its exit (text fade → logo shrink →
// removal). // removal). Runs once at root mount, whichever page loads first.
// Runs once at root mount, so it covers both the homepage and a fresh load of
// a tool page.
const loadingElement = document.getElementById('jn-loading'); const loadingElement = document.getElementById('jn-loading');
const appElement = document.getElementById('app'); const appElement = document.getElementById('app');
+6 -10
View File
@@ -14,10 +14,8 @@
</header> </header>
<!-- Card groups, one per category (data/tools.js TOOL_CATEGORIES). Each <!-- Card groups, one per category (data/tools.js TOOL_CATEGORIES). Each
card is a real <a> to the tool's /tools/:slug page, so ⌘/Ctrl-click, card is a real <a> to /tools/:slug: modified clicks stay the
middle-click and "open in new tab" stay with the browser; a plain browser's, a plain click / Enter / Space routes in-app. -->
left-click (or Enter / Space) is routed in-app instead, which keeps
this page alive behind the tool. -->
<div class="mt-4 space-y-6"> <div class="mt-4 space-y-6">
<div v-for="group in cardGroups" :key="group.id"> <div v-for="group in cardGroups" :key="group.id">
<h3 class="mb-3 text-sm font-medium tracking-wide text-muted-foreground"> <h3 class="mb-3 text-sm font-medium tracking-wide text-muted-foreground">
@@ -46,7 +44,6 @@
<span class="jn-emoji" aria-hidden="true">{{ card.emoji }}</span> <span class="jn-emoji" aria-hidden="true">{{ card.emoji }}</span>
</CardContent> </CardContent>
</a> </a>
</Card> </Card>
</div> </div>
</div> </div>
@@ -64,7 +61,8 @@ import { useMainStore } from '@/store';
import { useI18n } from 'vue-i18n'; import { useI18n } from 'vue-i18n';
import { trackEvent } from '@/utils/analytics'; import { trackEvent } from '@/utils/analytics';
import { ADVANCED_TOOLS, groupToolsByCategory } from '@/data/tools.js'; import { ADVANCED_TOOLS, groupToolsByCategory } from '@/data/tools.js';
import { isToolAvailable } from '@/utils/tool-availability.js'; import { listedTools } from '@/utils/tool-availability.js';
import { isPlainClick } from '@/utils/nav-target.js';
import { Card, CardContent } from '@/components/ui/card'; import { Card, CardContent } from '@/components/ui/card';
import InfoBanner from '@/components/widgets/InfoBanner.vue'; import InfoBanner from '@/components/widgets/InfoBanner.vue';
import { ArrowRight } from '@lucide/vue'; import { ArrowRight } from '@lucide/vue';
@@ -81,7 +79,7 @@ const router = useRouter();
// utils/tool-availability.js). Reactive on configs, so gated cards appear the // utils/tool-availability.js). Reactive on configs, so gated cards appear the
// moment configs land. // moment configs land.
const cardGroups = computed(() => groupToolsByCategory( const cardGroups = computed(() => groupToolsByCategory(
ADVANCED_TOOLS.filter((tool) => isToolAvailable(tool, configs.value)), listedTools(ADVANCED_TOOLS, configs.value),
)); ));
// Open a tool's page. Also the keyboard shortcuts' entry point (exposed below). // Open a tool's page. Also the keyboard shortcuts' entry point (exposed below).
@@ -91,10 +89,8 @@ const openTool = (slug) => {
trackEvent('Nav', 'NavClick', name); trackEvent('Nav', 'NavClick', name);
}; };
// Card left-click: route in-app. Modifier / middle clicks fall through to the
// <a href> default so the browser opens the page in a new tab.
const onCardClick = (e, slug) => { const onCardClick = (e, slug) => {
if (e.metaKey || e.ctrlKey || e.shiftKey || e.altKey || e.button === 1) return; if (!isPlainClick(e)) return;
e.preventDefault(); e.preventDefault();
openTool(slug); openTool(slug);
}; };
+5 -8
View File
@@ -23,14 +23,12 @@
</template> </template>
<script setup> <script setup>
// The homepage. Holds every top-level section plus the Advanced Tools cards; // The homepage (`/`): every top-level section plus the Advanced Tools cards;
// this component is what /'s <router-view> renders. What every route shares // what every route shares lives in App.vue.
// (the Nav, the account and preferences dialogs, tooltip provider, toast, PWA,
// theme) lives in App.vue.
// //
// Components — the test sections and the always-visible chrome load // Components — the test sections and the always-visible chrome load
// synchronously; everything the first paint can't show (dialogs, drawers, // synchronously; everything the first paint can't show (dialogs, the
// the below-fold Additional/Footer) is an async component so its code stays // below-fold Additional/Footer) is an async component so its code stays
// out of the route chunk and out of the mount's critical path. Their // out of the route chunk and out of the mount's critical path. Their
// template refs are null until the chunk lands — consumers (use-shortcuts) // template refs are null until the chunk lands — consumers (use-shortcuts)
// must optional-chain. // must optional-chain.
@@ -108,8 +106,7 @@ useSectionTracking();
// Localized homepage head. Provide title/description explicitly via t() rather // Localized homepage head. Provide title/description explicitly via t() rather
// than leaning on use-document-meta's DEFAULT_META snapshot: that snapshot is // than leaning on use-document-meta's DEFAULT_META snapshot: that snapshot is
// taken at module load, before the (now async) locale messages land, so it would // taken at module load, before the (now async) locale messages land, so it would
// pin the head to index.html's English title. Home is kept alive, so coming // pin the head to index.html's English title.
// back from another page re-applies this head on activation.
useDocumentMeta(() => ({ useDocumentMeta(() => ({
title: t('page.title'), title: t('page.title'),
description: t('page.description'), description: t('page.description'),
+12 -24
View File
@@ -1,8 +1,6 @@
<template> <template>
<!-- The iOS status bar is opaque system chrome (index.html sets no <!-- The iOS status bar is opaque (no apple-mobile-web-app-status-bar-style), so the
apple-mobile-web-app-status-bar-style), so the safe-area inset is only inset is non-zero only on landscape notch edges. jn-site-nav: see style.css. -->
non-zero where the page really runs under it (landscape notch edges).
The header stays put through page transitions (jn-site-nav in style.css). -->
<header <header
class="jn-site-nav fixed top-[env(safe-area-inset-top)] left-0 right-0 z-40 w-full border-b transition-transform duration-300 ease-out will-change-transform" class="jn-site-nav fixed top-[env(safe-area-inset-top)] left-0 right-0 z-40 w-full border-b transition-transform duration-300 ease-out will-change-transform"
:class="{ '-translate-y-full': isNavHidden, :class="{ '-translate-y-full': isNavHidden,
@@ -59,8 +57,7 @@
</NavigationMenuItem> </NavigationMenuItem>
</NavigationMenuList> </NavigationMenuList>
</NavigationMenu> </NavigationMenu>
<!-- All other sections: scroll there (from home first off the <!-- All other sections -->
homepage — utils/nav-target.js). -->
<a v-else href="#" :class="navLinkClass(item)" @click.prevent="goToSection(item)"> <a v-else href="#" :class="navLinkClass(item)" @click.prevent="goToSection(item)">
{{ t(`nav.${item}`) }} {{ t(`nav.${item}`) }}
</a> </a>
@@ -146,10 +143,8 @@
</template> </template>
<script setup> <script setup>
// The site navigation bar, rendered once by App.vue above every route. Its // The site navigation bar, rendered once by App.vue above every route; what a
// entries act per route (utils/nav-target.js): a section link scrolls the // section or tool entry does on the current route is utils/nav-target.js.
// homepage, or goes home first from any other page; a tool entry opens the
// tool's page.
import { ref, computed, watch, onMounted, onBeforeUnmount } from 'vue'; import { ref, computed, watch, onMounted, onBeforeUnmount } from 'vue';
import { useRoute, useRouter } from 'vue-router'; import { useRoute, useRouter } from 'vue-router';
import { useMainStore } from '@/store'; import { useMainStore } from '@/store';
@@ -190,14 +185,13 @@ const { navigateTo } = useNavTarget();
const isDarkMode = computed(() => store.isDarkMode); const isDarkMode = computed(() => store.isDarkMode);
const isMobile = computed(() => store.isMobile); const isMobile = computed(() => store.isMobile);
// Off the homepage no section is current (its tracker runs on Home only, so // store.currentSection keeps Home's last section, so it's ignored elsewhere.
// store.currentSection keeps the last one seen there).
const isHome = computed(() => route.name === 'home'); const isHome = computed(() => route.name === 'home');
const currentSection = computed(() => (isHome.value ? store.currentSection : null)); const currentSection = computed(() => (isHome.value ? store.currentSection : null));
const currentToolSlug = computed(() => (route.name === 'tool' ? String(route.params.slug) : '')); const currentToolSlug = computed(() => (route.name === 'tool' ? String(route.params.slug) : ''));
const homeHref = router.resolve('/').href; const homeHref = router.resolve('/').href;
const loaded = computed(() => store.allHasLoaded); const loaded = computed(() => store.allHasLoaded);
// The brand shimmers while the homepage tests run; the other pages run none. // The brand shimmers while the homepage tests run.
const shimmer = computed(() => isHome.value && !loaded.value); const shimmer = computed(() => isHome.value && !loaded.value);
// Running as an installed PWA (chromeless window) — see utils/pwa.js. // Running as an installed PWA (chromeless window) — see utils/pwa.js.
@@ -205,8 +199,7 @@ const isPwa = isRunningAsPwa();
const navItems = SECTION_IDS; const navItems = SECTION_IDS;
// Tools shown in the nav, mirroring Advanced.vue's enabledCards: gated tools // Gated tools stay hidden where the deployment lacks them, as in the card grid.
// stay hidden where the deployment lacks them. Reactive on configs.
const configs = computed(() => store.configs); const configs = computed(() => store.configs);
const advancedTools = computed(() => listedTools(ADVANCED_TOOLS, configs.value)); const advancedTools = computed(() => listedTools(ADVANCED_TOOLS, configs.value));
@@ -259,8 +252,7 @@ const handleLogoClick = (e) => {
trackEvent('Nav', 'NavClick', 'Logo'); trackEvent('Nav', 'NavClick', 'Logo');
}; };
// Section and tool entries close the mobile nav Sheet first (a no-op on // Section and tool entries close the mobile nav Sheet first (no-op on desktop).
// desktop) so it isn't left open over where they land.
const goToSection = (section) => { const goToSection = (section) => {
store.setOpenSheet(null); store.setOpenSheet(null);
navigateTo({ section }); navigateTo({ section });
@@ -305,8 +297,7 @@ const onScroll = () => {
}); });
}; };
// The listener runs on mobile only, on every route; attaching resyncs // Mobile only; attaching resyncs lastScrollY so the first scroll isn't a phantom jump.
// lastScrollY so the first scroll isn't a phantom jump.
const setScrollListener = (on) => { const setScrollListener = (on) => {
if (on) { if (on) {
lastScrollY = window.scrollY; lastScrollY = window.scrollY;
@@ -319,12 +310,9 @@ const setScrollListener = (on) => {
watch(isMobile, (mobile) => { watch(isMobile, (mobile) => {
if (!mobile) isNavHidden.value = false; if (!mobile) isNavHidden.value = false;
setScrollListener(mobile); setScrollListener(mobile);
}); }, { immediate: true });
onMounted(() => { onMounted(fetchGithubStars);
if (isMobile.value) setScrollListener(true);
fetchGithubStars();
});
onBeforeUnmount(() => { onBeforeUnmount(() => {
setScrollListener(false); setScrollListener(false);
+5 -11
View File
@@ -1,13 +1,8 @@
<template> <template>
<!-- Breadcrumb at the top of a page body, above its <h1>: <!-- Markup and classes follow shadcn-vue's Breadcrumb (ui/ has no copy-in). -->
← Home / Advanced Tools / 🛂 ASN Profile
Markup and classes follow shadcn-vue's Breadcrumb, written out here as
ui/ has no breadcrumb copy-in. Every crumb but the last is a real link
to / (modified clicks open a new tab); the last is the page itself. -->
<nav aria-label="breadcrumb" class="mb-3"> <nav aria-label="breadcrumb" class="mb-3">
<ol class="text-muted-foreground flex flex-wrap items-center gap-1.5 text-sm break-words sm:gap-2.5"> <ol class="text-muted-foreground flex flex-wrap items-center gap-1.5 text-sm break-words sm:gap-2.5">
<!-- Home: back in history when the homepage is the previous entry <!-- Home: back in history when Home is the previous entry, so it returns as left -->
(restores it as it was left), a push of / otherwise. -->
<li class="inline-flex items-center gap-1.5"> <li class="inline-flex items-center gap-1.5">
<a :href="homeHref" class="inline-flex items-center gap-1 transition-colors hover:text-foreground" <a :href="homeHref" class="inline-flex items-center gap-1 transition-colors hover:text-foreground"
@click="onHome"> @click="onHome">
@@ -30,10 +25,9 @@
</template> </template>
<script setup> <script setup>
// Page breadcrumb for the pages other than the homepage (tool pages, /privacy, // Breadcrumb above the <h1> of every page but Home: ← Home / … / current page.
// /r/:id). `items` are the crumbs after Home, already localized; the last one // `items` are the localized crumbs after Home; the last is the current page,
// is the current page, the others name a homepage section to scroll to // the others `{ label, section }` — a homepage section, reached as the Nav does.
// (`{ label, section }`, through the same helper as the Nav).
import { useRouter } from 'vue-router'; import { useRouter } from 'vue-router';
import { useI18n } from 'vue-i18n'; import { useI18n } from 'vue-i18n';
import { ArrowLeft } from '@lucide/vue'; import { ArrowLeft } from '@lucide/vue';
-1
View File
@@ -1,7 +1,6 @@
<template> <template>
<!-- Standalone privacy page at /privacy (shareable + crawlable) --> <!-- Standalone privacy page at /privacy (shareable + crawlable) -->
<div class="flex min-h-[calc(100dvh_-_3.5rem_-_env(safe-area-inset-top))] flex-col"> <div class="flex min-h-[calc(100dvh_-_3.5rem_-_env(safe-area-inset-top))] flex-col">
<!-- Content: the breadcrumb, then the policy -->
<main class="flex-1 mx-auto w-full max-w-[760px] px-4 md:px-6 py-8"> <main class="flex-1 mx-auto w-full max-w-[760px] px-4 md:px-6 py-8">
<PageBreadcrumb :items="[{ label: t('about.Privacy') }]" /> <PageBreadcrumb :items="[{ label: t('about.Privacy') }]" />
+9 -18
View File
@@ -1,12 +1,7 @@
<template> <template>
<!-- The page of one advanced tool, at /tools/:slug — where every opener <!-- The page of one advanced tool, /tools/:slug. At least a viewport tall
(cards, nav, shortcuts, links) lands. App.vue keeps this page alive, below the fixed Nav, so the footer sits at the bottom of a short page. -->
and the inner KeepAlive below keeps every visited tool alive per slug.
At least a viewport tall below the fixed Nav, so the footer sits at
the bottom of a short page. -->
<div class="flex min-h-[calc(100dvh_-_3.5rem_-_env(safe-area-inset-top))] flex-col"> <div class="flex min-h-[calc(100dvh_-_3.5rem_-_env(safe-area-inset-top))] flex-col">
<!-- Content: the breadcrumb, an <h1> for the tool (SEO), then the tool
body itself -->
<main class="flex-1"> <main class="flex-1">
<div class="mx-auto w-full max-w-[1400px] px-4 md:px-6 py-6"> <div class="mx-auto w-full max-w-[1400px] px-4 md:px-6 py-6">
<PageBreadcrumb v-if="tool" :items="breadcrumb" /> <PageBreadcrumb v-if="tool" :items="breadcrumb" />
@@ -14,8 +9,7 @@
<span aria-hidden="true">{{ tool.emoji }}</span> <span aria-hidden="true">{{ tool.emoji }}</span>
{{ t(tool.titleKey) }} {{ t(tool.titleKey) }}
</h1> </h1>
<!-- One cached instance per visited slug; re-keyed by the auth epoch <!-- One cached instance per visited slug -->
so a sign-out or account switch drops them all. -->
<KeepAlive :key="authEpoch" :max="8"> <KeepAlive :key="authEpoch" :max="8">
<component :is="toolComponent" v-if="toolComponent" :key="tool.slug" /> <component :is="toolComponent" v-if="toolComponent" :key="tool.slug" />
</KeepAlive> </KeepAlive>
@@ -40,9 +34,8 @@ import { ToolLoadingSkeleton } from '@/components/ui/tool-loading-skeleton';
defineOptions({ name: 'ToolPage' }); defineOptions({ name: 'ToolPage' });
// One async wrapper per slug, built once, so re-renders never rebuild it — a // One async wrapper per slug, built once: a new wrapper would remount the tool
// new wrapper would remount the tool and lose its cached state. // and lose its cached state. `delay` keeps fast loads skeleton-free.
// The skeleton covers the chunk download; `delay` keeps fast loads flash-free.
const asyncToolCache = new Map(); const asyncToolCache = new Map();
const asyncToolFor = (tool) => { const asyncToolFor = (tool) => {
if (!asyncToolCache.has(tool.slug)) { if (!asyncToolCache.has(tool.slug)) {
@@ -60,8 +53,7 @@ const route = useRoute();
const router = useRouter(); const router = useRouter();
const store = useMainStore(); const store = useMainStore();
// The slug of the tool route, held while another route shows: this page stays // Held while another route shows: the cached page keeps rendering its tool.
// cached then, and must keep rendering the tool it was showing.
const routeSlug = computed(() => (route.name === 'tool' ? route.params.slug : null)); const routeSlug = computed(() => (route.name === 'tool' ? route.params.slug : null));
const slug = computed((previous) => routeSlug.value ?? previous ?? null); const slug = computed((previous) => routeSlug.value ?? previous ?? null);
@@ -83,14 +75,13 @@ useDocumentMeta(() => {
}; };
}); });
// Unknown slug → bounce to the homepage rather than show an empty shell. // Unknown slug → homepage, on every arrival (a cached instance included).
// Runs on every arrival at a tool route, including a reused instance.
watch(routeSlug, (next) => { watch(routeSlug, (next) => {
if (next && !tool.value) router.replace('/'); if (next && !tool.value) router.replace('/');
}, { immediate: true }); }, { immediate: true });
// Auth epoch: bumped when the signed-in account goes away (utils/tool-cache.js), // Re-keys the KeepAlive when the account goes away (utils/tool-cache.js), so
// which re-keys the inner KeepAlive and so drops every cached tool. // a sign-out or account switch drops every cached tool.
const authEpoch = ref(0); const authEpoch = ref(0);
watch(() => store.user?.uid ?? null, (nextUid, prevUid) => { watch(() => store.user?.uid ?? null, (nextUid, prevUid) => {
if (shouldDropToolCache(prevUid, nextUid)) authEpoch.value += 1; if (shouldDropToolCache(prevUid, nextUid)) authEpoch.value += 1;
+4 -10
View File
@@ -123,8 +123,6 @@ const store = useMainStore();
const route = useRoute(); const route = useRoute();
const { dotClass } = useStatusTone(); const { dotClass } = useStatusTone();
// One instance for the whole site (App.vue). The pages other than the
// homepage skip its loading pipeline, which changes when user info can load;
// `matched` is empty until the first navigation resolves the route. // `matched` is empty until the first navigation resolves the route.
const isOffHome = computed(() => route.matched.length > 0 && route.name !== 'home'); const isOffHome = computed(() => route.matched.length > 0 && route.name !== 'home');
@@ -250,12 +248,8 @@ const updateUserAchievement = async (achievementName) => {
} }
}; };
// User info loads once the visitor is signed in and, on the homepage, its // User info loads once signed in and, on Home, after its tests (allHasLoaded);
// tests have finished (allHasLoaded); the other pages never run them, so // other pages never run them. getUserInfo self-guards against double fetches.
// sign-in alone is enough there. Sign-in can land after allHasLoaded (the
// unknown-auth-hint boot probes in the background, main.js) and auth can
// resolve before this mounts (immediate). getUserInfo self-guards against
// double fetches.
watch(() => isSignedIn.value && (store.allHasLoaded || isOffHome.value), (ready) => { watch(() => isSignedIn.value && (store.allHasLoaded || isOffHome.value), (ready) => {
if (ready) getUserInfo(); if (ready) getUserInfo();
}, { immediate: true }); }, { immediate: true });
@@ -264,12 +258,12 @@ watch(() => triggerUserBenefits.value, (newVal) => {
if (newVal) openUserBenefits(); if (newVal) openUserBenefits();
}); });
// The dialog belongs to the page it was opened on: a page change closes it. // A page change closes the dialog.
watch(() => route.path, () => { watch(() => route.path, () => {
isOpen.value = false; isOpen.value = false;
}); });
// One-shot trigger: cleared here so the next request from Nav / Achievements // One-shot trigger: cleared here so the next request from UserMenu / Achievements
// is a fresh false → true edge rather than a no-op write. // is a fresh false → true edge rather than a no-op write.
watch(() => triggerRemoteUserInfo.value, (newVal) => { watch(() => triggerRemoteUserInfo.value, (newVal) => {
if (!newVal) return; if (!newVal) return;
+4 -12
View File
@@ -1,11 +1,4 @@
<template> <template>
<!-- Account menu at the right of the site Nav. Two shapes:
a Firebase-less self-hosted instance has no user system, so only the
preferences cog shows; otherwise a dropdown carries sign-in (signed
out) or the account card + achievements (signed in), then preferences,
benefits & usage and sign-out. The dialogs it opens are raised through
the store: Preferences and User live in App.vue, Achievements on the
homepage. -->
<!-- Firebase-less: standalone preferences cog. --> <!-- Firebase-less: standalone preferences cog. -->
<JnTooltip v-if="!isFireBaseSet" :text="t('nav.preferences.title')"> <JnTooltip v-if="!isFireBaseSet" :text="t('nav.preferences.title')">
<Button variant="ghost" size="icon" class="size-8 cursor-pointer" aria-label="Open preferences" <Button variant="ghost" size="icon" class="size-8 cursor-pointer" aria-label="Open preferences"
@@ -119,8 +112,9 @@
</template> </template>
<script setup> <script setup>
// Account menu (preferences cog or user dropdown) for the site Nav. // Account menu at the right of the site Nav: a preferences cog on a
// Everything it opens goes through store triggers. // Firebase-less instance, the user dropdown otherwise. What it opens is raised
// through store triggers (Preferences / User live in App.vue, Achievements on Home).
import { computed } from 'vue'; import { computed } from 'vue';
import { useRoute, useRouter } from 'vue-router'; import { useRoute, useRouter } from 'vue-router';
import { useI18n } from 'vue-i18n'; import { useI18n } from 'vue-i18n';
@@ -176,14 +170,12 @@ const getUserInfo = async () => {
store.setTriggerRemoteUserInfo(true); store.setTriggerRemoteUserInfo(true);
}; };
// Preferences: the one site-wide sheet (App.vue).
const openPreferences = () => { const openPreferences = () => {
store.toggleSheet('preferences'); store.toggleSheet('preferences');
trackEvent('Nav', 'NavClick', 'Preferences'); trackEvent('Nav', 'NavClick', 'Preferences');
}; };
// Achievements live on the homepage only: from any other page, go home first // Achievements live on Home only, so go there first.
// (the kept-alive Home comes back as it was left), then raise the sheet.
const openAchievements = async () => { const openAchievements = async () => {
if (route.name !== 'home') { if (route.name !== 'home') {
const failure = await router.push('/'); const failure = await router.push('/');
@@ -536,10 +536,8 @@ const onSubmit = () => {
if (query.value.trim()) start(query.value); if (query.value.trim()) start(query.value);
}; };
// `?q=` on mount, any later change while the tool is on screen (an ASN link // Shareable `?q=` (use-route-active.js), including an ASN link followed from
// followed from inside it, history navigation), and a `?q=` that changed while // inside the page; our own write-back equals `query`, so it doesn't run twice.
// it was cached away (use-route-active.js). Our own write-back equals `query`
// already, so it doesn't run twice.
const sharedQuery = useActiveValue(() => route.query.q, { pathOf: () => route.path }); const sharedQuery = useActiveValue(() => route.query.q, { pathOf: () => route.path });
watch(sharedQuery, (q) => { watch(sharedQuery, (q) => {
if (typeof q === 'string' && q.trim() && q !== query.value) { if (typeof q === 'string' && q.trim() && q !== query.value) {
@@ -33,7 +33,7 @@
<span> <span>
{{ t('user.QuotaExceeded') }} {{ t('user.QuotaExceeded') }}
<button type="button" class="underline underline-offset-2 cursor-pointer" <button type="button" class="underline underline-offset-2 cursor-pointer"
@click="openUsageDialog">{{ t('user.ViewUsage') }}</button> @click="store.setTriggerUserBenefits(true)">{{ t('user.ViewUsage') }}</button>
</span> </span>
</div> </div>
@@ -295,11 +295,6 @@ const { t } = useI18n();
const store = useMainStore(); const store = useMainStore();
const lang = computed(() => store.lang); const lang = computed(() => store.lang);
// Open the Benefits & Usage dialog (App.vue hosts the User dialogs).
const openUsageDialog = () => {
store.setTriggerUserBenefits(true);
};
// NS-capture session config. Token + probe FQDNs are generated client-side; // NS-capture session config. Token + probe FQDNs are generated client-side;
// the NS passively captures any {nonce}.{token}.{DOMAIN} that shows up. // the NS passively captures any {nonce}.{token}.{DOMAIN} that shows up.
const PROBE_DOMAIN = 'leak.ipcheck.ing'; const PROBE_DOMAIN = 'leak.ipcheck.ing';
@@ -6,9 +6,7 @@
<p v-if="!isMobile">{{ t('pingtest.Note2') }}</p> <p v-if="!isMobile">{{ t('pingtest.Note2') }}</p>
</div> </div>
<!-- Input area. With stored IPs (the homepage's IP cards have run) the <!-- Input area -->
user can pick one OR switch to manual entry; on a direct visit to
the tool's page allIPs is empty, so it's manual entry only. -->
<div class="space-y-2"> <div class="space-y-2">
<div class="flex items-center justify-between gap-2"> <div class="flex items-center justify-between gap-2">
<Label :for="manualMode ? 'pingIPManual' : 'pingIP'" class="font-medium"> <Label :for="manualMode ? 'pingIPManual' : 'pingIP'" class="font-medium">
@@ -207,9 +205,8 @@ const pickerSections = computed(() => [
{ key: 'suggested', label: t('globalping.Suggested'), countries: GLOBALPING_SUGGESTED_COUNTRIES }, { key: 'suggested', label: t('globalping.Suggested'), countries: GLOBALPING_SUGGESTED_COUNTRIES },
]); ]);
// Manual entry is forced when there are no stored IPs (the standalone page, // Manual entry is forced when no IPs are stored (the homepage never ran);
// where the homepage never ran). When stored IPs exist, the "use stored IP" // otherwise the switch (on by default) picks between dropdown and typing.
// switch (on by default) toggles between the dropdown and manual entry.
const useStored = ref(true); const useStored = ref(true);
const manualMode = computed(() => allIPs.value.length === 0 || !useStored.value); const manualMode = computed(() => allIPs.value.length === 0 || !useStored.value);
const manualIP = ref(''); const manualIP = ref('');
@@ -43,7 +43,7 @@
<span> <span>
{{ t('user.QuotaExceeded') }} {{ t('user.QuotaExceeded') }}
<button type="button" class="underline underline-offset-2 cursor-pointer" <button type="button" class="underline underline-offset-2 cursor-pointer"
@click="openUsageDialog">{{ t('user.ViewUsage') }}</button> @click="store.setTriggerUserBenefits(true)">{{ t('user.ViewUsage') }}</button>
</span> </span>
</div> </div>
</div> </div>
@@ -188,11 +188,6 @@ const { t } = useI18n();
const store = useMainStore(); const store = useMainStore();
// Open the Benefits & Usage dialog (App.vue hosts the User dialogs).
const openUsageDialog = () => {
store.setTriggerUserBenefits(true);
};
const checkingStatus = ref('idle'); const checkingStatus = ref('idle');
const errorMsg = ref(''); const errorMsg = ref('');
const quotaExceeded = ref(false); const quotaExceeded = ref(false);
@@ -6,9 +6,7 @@
<p>{{ t('ipblocklist.Note2') }}</p> <p>{{ t('ipblocklist.Note2') }}</p>
</div> </div>
<!-- Input area. With stored IPs (the homepage's IP cards have run) the <!-- Input area -->
user can pick one OR switch to manual entry; on a direct visit to
the tool's page allIPs is empty, so it's manual entry only. -->
<div class="space-y-2"> <div class="space-y-2">
<div class="flex items-center justify-between gap-2"> <div class="flex items-center justify-between gap-2">
<Label :for="manualMode ? 'blocklistIPManual' : 'blocklistIP'" class="font-medium"> <Label :for="manualMode ? 'blocklistIPManual' : 'blocklistIP'" class="font-medium">
@@ -70,7 +68,7 @@
<span> <span>
{{ t('user.QuotaExceeded') }} {{ t('user.QuotaExceeded') }}
<button type="button" class="underline underline-offset-2 cursor-pointer" <button type="button" class="underline underline-offset-2 cursor-pointer"
@click="openUsageDialog">{{ t('user.ViewUsage') }}</button> @click="store.setTriggerUserBenefits(true)">{{ t('user.ViewUsage') }}</button>
</span> </span>
</div> </div>
</div> </div>
@@ -230,11 +228,6 @@ const { t, te } = useI18n();
const store = useMainStore(); const store = useMainStore();
const lang = computed(() => store.lang); const lang = computed(() => store.lang);
// Open the Benefits & Usage dialog (App.vue hosts the User dialogs).
const openUsageDialog = () => {
store.setTriggerUserBenefits(true);
};
// The backend waits up to 30s on the upstream; give it room to answer first. // The backend waits up to 30s on the upstream; give it room to answer first.
const CLIENT_TIMEOUT_MS = 35000; const CLIENT_TIMEOUT_MS = 35000;
@@ -248,7 +241,7 @@ const TONE_ICON = { abuse: CircleX, anonymity: CircleAlert, notice: Info, clean:
const LEGEND = ['abuse', 'anonymity', 'notice', 'clean', 'error']; const LEGEND = ['abuse', 'anonymity', 'notice', 'clean', 'error'];
// ── Input ──────────────────────────────────────────────────────────────── // ── Input ────────────────────────────────────────────────────────────────
// Manual entry is forced when there are no stored IPs (the standalone page); // Manual entry is forced when no IPs are stored (the homepage never ran);
// otherwise the switch (on by default) picks between dropdown and typing. // otherwise the switch (on by default) picks between dropdown and typing.
const allIPs = computed(() => selectableIPs(store.allIPs)); const allIPs = computed(() => selectableIPs(store.allIPs));
const useStored = ref(true); const useStored = ref(true);
@@ -3,10 +3,9 @@
MAC Lookup's job) dispatched to the matching card under ./ip-calculator/. MAC Lookup's job) dispatched to the matching card under ./ip-calculator/.
Pure local computation via utils/features/ip-calc.js. Pure local computation via utils/features/ip-calc.js.
The query rides the URL as `?q=` on /tools/ipcalculator, written back on The query rides the URL as `?q=`, written back on every run so results
every run so results are shareable. Two collapsed folds under the input are shareable. Two collapsed folds under the input hold example pills
hold example pills (one per accepted syntax) and, once the homepage has (one per accepted syntax) and the visitor's own IPs (store.allIPs). -->
detected them, the visitor's own IPs (store.allIPs). -->
<template> <template>
<div class="ip-calculator-section my-4 space-y-4"> <div class="ip-calculator-section my-4 space-y-4">
<!-- Top note --> <!-- Top note -->
@@ -201,10 +200,8 @@ const onPrefixCommit = (prefix) => {
syncQuery(); syncQuery();
}; };
// `?q=` on mount, any later change while the tool is on screen (history // Shareable `?q=` (use-route-active.js); our own write-back equals the
// navigation), and a `?q=` that changed while it was cached away // trimmed input, so it doesn't run twice.
// (use-route-active.js). Our own write-back equals the trimmed input, so it
// doesn't run twice.
const sharedQuery = useActiveValue(() => route.query.q, { pathOf: () => route.path }); const sharedQuery = useActiveValue(() => route.query.q, { pathOf: () => route.path });
watch(sharedQuery, (q) => { watch(sharedQuery, (q) => {
if (typeof q === 'string' && q.trim() && q !== query.value.trim()) { if (typeof q === 'string' && q.trim() && q !== query.value.trim()) {
@@ -6,9 +6,7 @@
<p v-if="!isMobile">{{ t('mtrtest.Note2') }}</p> <p v-if="!isMobile">{{ t('mtrtest.Note2') }}</p>
</div> </div>
<!-- Input area. With stored IPs (the homepage's IP cards have run) the <!-- Input area -->
user can pick one OR switch to manual entry; on a direct visit to
the tool's page allIPs is empty, so it's manual entry only. -->
<div class="space-y-2"> <div class="space-y-2">
<div class="flex items-center justify-between gap-2"> <div class="flex items-center justify-between gap-2">
<Label :for="manualMode ? 'mtrIPManual' : 'mtrIP'" class="font-medium"> <Label :for="manualMode ? 'mtrIPManual' : 'mtrIP'" class="font-medium">
@@ -235,9 +233,8 @@ const pickerSections = computed(() => [
{ key: 'suggested', label: t('globalping.Suggested'), countries: GLOBALPING_SUGGESTED_COUNTRIES }, { key: 'suggested', label: t('globalping.Suggested'), countries: GLOBALPING_SUGGESTED_COUNTRIES },
]); ]);
// Manual entry is forced when there are no stored IPs (the standalone page, // Manual entry is forced when no IPs are stored (the homepage never ran);
// where the homepage never ran). When stored IPs exist, the "use stored IP" // otherwise the switch (on by default) picks between dropdown and typing.
// switch (on by default) toggles between the dropdown and manual entry.
const useStored = ref(true); const useStored = ref(true);
const manualMode = computed(() => allIPs.value.length === 0 || !useStored.value); const manualMode = computed(() => allIPs.value.length === 0 || !useStored.value);
const manualIP = ref(''); const manualIP = ref('');
@@ -233,7 +233,7 @@
<span> <span>
{{ t('user.QuotaExceeded') }} {{ t('user.QuotaExceeded') }}
<button type="button" class="underline underline-offset-2 cursor-pointer" <button type="button" class="underline underline-offset-2 cursor-pointer"
@click="openUsageDialog">{{ t('user.ViewUsage') }}</button> @click="store.setTriggerUserBenefits(true)">{{ t('user.ViewUsage') }}</button>
</span> </span>
</div> </div>
</div> </div>
@@ -383,11 +383,8 @@ const runDependencies = async () => {
if (!missing.length) return; if (!missing.length) return;
runningDependencies.value = true; runningDependencies.value = true;
try { try {
// The owners are the homepage sections, registered while Home is // The owners are homepage sections: a cached Home runs them in place;
// mounted — kept alive behind this page once it has been visited, so // one that never mounted (direct visit) needs a trip home first.
// the tests run in place and report back over the app-events bus.
// On a direct visit Home has never mounted: go home first, then wait
// for each command to be registered before dispatching.
const commands = missing.map((source) => DEPENDENCY_COMMANDS[source]); const commands = missing.map((source) => DEPENDENCY_COMMANDS[source]);
if (!commands.every(hasAppCommand)) await router.push('/'); if (!commands.every(hasAppCommand)) await router.push('/');
await Promise.allSettled(commands.map(async (command) => { await Promise.allSettled(commands.map(async (command) => {
@@ -410,11 +407,6 @@ const quotaExceeded = ref(false);
const canRun = computed(() => const canRun = computed(() =>
signedIn.value && hasProfile.value && !missingSources.value.length); signedIn.value && hasProfile.value && !missingSources.value.length);
// Open the Benefits & Usage dialog (App.vue hosts the User dialogs).
const openUsageDialog = () => {
store.setTriggerUserBenefits(true);
};
const run = async () => { const run = async () => {
// Pre-flight gate, same as the other quota-metered tools: when the local // Pre-flight gate, same as the other quota-metered tools: when the local
// snapshot already says the month is spent, every run would 429. // snapshot already says the month is spent, every run would 429.
@@ -443,10 +443,8 @@ const changeList = (listName, shouldScroll = true) => {
if (previousList !== currentList.value) { if (previousList !== currentList.value) {
trackEvent('SecurityChecklist', 'SecurityChecklist', 'ChangeList'); trackEvent('SecurityChecklist', 'SecurityChecklist', 'ChangeList');
} }
// Bring the details into view when switching category on a narrow // On a narrow screen the details stack below the list: bring them into
// screen, where they stack below the category list. `scroll-mt-*` on // view, clear of the fixed Nav (scroll-mt-* on #checklist).
// #checklist keeps them clear of the fixed Nav; on md+ they
// sit beside the list with their top on screen, so nothing moves.
nextTick(() => { nextTick(() => {
const el = document.getElementById('checklist'); const el = document.getElementById('checklist');
if (!el) return; if (!el) return;
@@ -1,9 +1,8 @@
<template> <template>
<!-- Service Status advanced tool: shows whether a set of well-known <!-- Service Status advanced tool: shows whether a set of well-known
products are currently up, expandable per-card into sub-services and products are currently up, expandable per-card into sub-services and
recent incidents. Rendered by the tool page (ToolPage.vue owns the recent incidents. ToolPage.vue owns the title, so this starts at the
title), so this template starts at the note row rather than a section note row. -->
<h2>. -->
<div class="service-status-section my-4 space-y-4"> <div class="service-status-section my-4 space-y-4">
<!-- Top note --> <!-- Top note -->
<p class="text-sm text-muted-foreground leading-relaxed">{{ t('serviceStatus.Note') }}</p> <p class="text-sm text-muted-foreground leading-relaxed">{{ t('serviceStatus.Note') }}</p>
@@ -165,10 +164,8 @@
// /api/service-status/detail → one provider's sub-services + incidents (on expand) // /api/service-status/detail → one provider's sub-services + incidents (on expand)
// so the initial load stays light and detail is pulled only when a card opens. // so the initial load stays light and detail is pulled only when a card opens.
// //
// An Advanced Tool, not a homepage section: it loads on its first mount // Not wired into the homepage's global-refresh orchestrator: it loads on
// (loadOverview), stays cached with its page afterwards, and is not wired // mount, and the bottom Refresh button is the only re-pull path.
// into the homepage's global-refresh orchestrator — the bottom Refresh button
// here is the only re-pull path.
import { ref, reactive, computed, onMounted } from 'vue'; import { ref, reactive, computed, onMounted } from 'vue';
import { useMainStore } from '@/store'; import { useMainStore } from '@/store';
import { useI18n } from 'vue-i18n'; import { useI18n } from 'vue-i18n';
+5 -7
View File
@@ -27,8 +27,7 @@
<!-- Traffic shares (pair bars) --> <!-- Traffic shares (pair bars) -->
<AsnTrafficShares :info="asnInfos[asn]" /> <AsnTrafficShares :info="asnInfos[asn]" />
<!-- Full profile: a real link to the ASN Profile page (new tab on <!-- Full profile: a real link; a plain click routes in-app -->
modifier clicks); a plain click routes in-app -->
<div v-if="profileHref" class="pt-1"> <div v-if="profileHref" class="pt-1">
<Button as-child variant="outline" size="sm" class="h-7 cursor-pointer gap-1.5 text-xs"> <Button as-child variant="outline" size="sm" class="h-7 cursor-pointer gap-1.5 text-xs">
<a :href="profileHref" @click="openProfile"> <a :href="profileHref" @click="openProfile">
@@ -58,6 +57,7 @@ import { trackEvent } from '@/utils/analytics';
import { parseAsnInput } from '@/utils/ip/asn-input.js'; import { parseAsnInput } from '@/utils/ip/asn-input.js';
import { isToolAvailable } from '@/utils/tool-availability.js'; import { isToolAvailable } from '@/utils/tool-availability.js';
import { TOOL_BY_SLUG } from '@/data/tools.js'; import { TOOL_BY_SLUG } from '@/data/tools.js';
import { isPlainClick } from '@/utils/nav-target.js';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { Icon } from '@iconify/vue'; import { Icon } from '@iconify/vue';
import { ArrowUpRight } from '@lucide/vue'; import { ArrowUpRight } from '@lucide/vue';
@@ -75,8 +75,7 @@ const props = defineProps({
asnInfos: { type: Object, required: true } asnInfos: { type: Object, required: true }
}); });
// Fired before a plain-click open, so a host dialog (QueryIP) closes before // Fired before a plain-click open, so a host dialog (QueryIP) closes first.
// the page changes under it.
const emit = defineEmits(['open-profile']); const emit = defineEmits(['open-profile']);
// Extract basic information (non-pair data) // Extract basic information (non-pair data)
@@ -94,8 +93,7 @@ const basicInfo = computed(() => {
}); });
// ASN Profile entry: hidden when the tool is gated off (no Cloudflare key) // ASN Profile entry: hidden when the tool is gated off (no Cloudflare key)
// or the ASN doesn't parse. Opens like an Advanced Tools card: the tool's // or the ASN doesn't parse; `q` makes the tool run the lookup.
// page, with `q` making the tool run the lookup.
const router = useRouter(); const router = useRouter();
const profileAsn = computed(() => { const profileAsn = computed(() => {
if (!isToolAvailable(TOOL_BY_SLUG.get('asn'), store.configs)) return null; if (!isToolAvailable(TOOL_BY_SLUG.get('asn'), store.configs)) return null;
@@ -105,7 +103,7 @@ const profileAsn = computed(() => {
const profileHref = computed(() => (profileAsn.value ? `/tools/asn?q=${profileAsn.value}` : '')); const profileHref = computed(() => (profileAsn.value ? `/tools/asn?q=${profileAsn.value}` : ''));
const openProfile = (e) => { const openProfile = (e) => {
if (e.metaKey || e.ctrlKey || e.shiftKey || e.altKey || e.button === 1) return; if (!isPlainClick(e)) return;
e.preventDefault(); e.preventDefault();
trackEvent('IPCheck', 'ASNProfileClick', 'Open ASN Profile'); trackEvent('IPCheck', 'ASNProfileClick', 'Open ASN Profile');
emit('open-profile'); emit('open-profile');
@@ -383,10 +383,8 @@ const props = defineProps({
countryTrafficMode: { type: String, default: 'off' }, countryTrafficMode: { type: String, default: 'off' },
}); });
// Consumers rendering this panel inside a dialog listen to close themselves // A host dialog listens to close itself first: before the Benefits & Usage
// first — before the Benefits & Usage dialog would stack on top of them // dialog stacks on it (`view-usage`) or ASN Info's profile link changes page (`open-tool`).
// (`view-usage`), or the ASN Profile page replaces the page under them
// (`open-tool`, from ASN Info's profile link).
const emit = defineEmits(['view-usage', 'open-tool']); const emit = defineEmits(['view-usage', 'open-tool']);
const openUsageDialog = () => { const openUsageDialog = () => {
+1 -2
View File
@@ -226,8 +226,7 @@ const store = useMainStore();
const userPreferences = computed(() => store.userPreferences); const userPreferences = computed(() => store.userPreferences);
const ipDBs = computed(() => store.ipDBs); const ipDBs = computed(() => store.ipDBs);
// One sheet for the whole site (App.vue), open while store.openSheet says so; // One site-wide sheet (App.vue, which closes it on a page change).
// App.vue clears that on a page change.
const isOpen = computed(() => store.openSheet === 'preferences'); const isOpen = computed(() => store.openSheet === 'preferences');
const onOpenChange = (val) => { const onOpenChange = (val) => {
store.setOpenSheet(val ? 'preferences' : null); store.setOpenSheet(val ? 'preferences' : null);
+1 -2
View File
@@ -22,6 +22,7 @@ const STYLE_TO_TOAST = {
'text-info': toast.info, 'text-info': toast.info,
}; };
// immediate: an alert raised before mount (a failed redirect sign-in at boot) shows too.
watch(alert, (newVal) => { watch(alert, (newVal) => {
if (!newVal || !newVal.alertToShow) { if (!newVal || !newVal.alertToShow) {
return; return;
@@ -31,8 +32,6 @@ watch(alert, (newVal) => {
description: newVal.alertMessage, description: newVal.alertMessage,
duration: newVal.alertDuration || 2000, duration: newVal.alertDuration || 2000,
}); });
// `immediate`: an alert raised before mount (a failed redirect sign-in is
// handled in the boot sequence, store.completeRedirectSignIn) is shown too.
}, { deep: true, immediate: true }); }, { deep: true, immediate: true });
</script> </script>
+3 -6
View File
@@ -2,10 +2,8 @@
// //
// There's no SSR, so this runs client-side: each routed page (Home.vue, the // There's no SSR, so this runs client-side: each routed page (Home.vue, the
// tool page, /privacy, /r/:id) calls useDocumentMeta() with its own values. // tool page, /privacy, /r/:id) calls useDocumentMeta() with its own values.
// Only the page on screen writes: a page kept alive by App.vue's KeepAlive // Only the active route writes, so a kept-alive page re-applies its head on
// stops writing while hidden and re-applies its head when it is activated // return. Reactive: locale switches and slug changes re-apply automatically.
// again (use-route-active.js) — no restore bookkeeping needed. It's reactive
// (watchEffect), so locale switches and slug changes re-apply automatically.
// //
// index.html ships sensible homepage defaults (used on first paint and as the // index.html ships sensible homepage defaults (used on first paint and as the
// fallback here); this just overrides them once Vue is driving the page. // fallback here); this just overrides them once Vue is driving the page.
@@ -14,8 +12,7 @@ import { watchEffect } from 'vue';
import { useRouteActive } from './use-route-active.js'; import { useRouteActive } from './use-route-active.js';
// Snapshot the index.html-provided values once, before any route overrides // Snapshot the index.html-provided values once, before any route overrides
// them — Home.vue reuses these so SPA-navigating back to `/` restores the // them: the fallback for fields a page leaves out.
// homepage head.
export const DEFAULT_META = { export const DEFAULT_META = {
title: document.title, title: document.title,
description: document.head.querySelector('meta[name="description"]')?.getAttribute('content') || '', description: document.head.querySelector('meta[name="description"]')?.getAttribute('content') || '',
+5 -12
View File
@@ -1,16 +1,9 @@
// Carries out a site-navigation click (utils/nav-target.js decides what it // Carries out a site-navigation click (utils/nav-target.js decides what it
// means on the current route): scroll the homepage to a section, go home first // means on the current route): scroll the homepage, go home then scroll, or
// and then scroll, or open a tool's page. // open a tool's page. Going home, the scroll waits a tick and a frame so it
// // runs after the router's scrollBehavior; then, as a freshly mounted homepage
// Going home is a push, so the router's scrollBehavior puts the homepage at // is still resizing above the section, it re-aims while #mainpart resizes,
// the top after the next tick; the section scroll waits for that tick and one // for SETTLE_MS or until the visitor scrolls or presses a key.
// frame, so it runs last, on the activated and laid-out homepage. A homepage
// this push mounted is still filling in (its tests and async parts resize
// what sits above the section), so the scroll re-aims while #mainpart
// resizes, for SETTLE_MS or until the visitor scrolls or presses a key.
//
// const { navigateTo } = useNavTarget(); // { route, router } injectable
// navigateTo({ section: 'Connectivity' }) / navigateTo({ tool: 'whois' })
import { nextTick } from 'vue'; import { nextTick } from 'vue';
import { useRoute, useRouter } from 'vue-router'; import { useRoute, useRouter } from 'vue-router';
@@ -8,10 +8,8 @@
// Sync flush on purpose: a keystroke arriving in the same tick as the open // Sync flush on purpose: a keystroke arriving in the same tick as the open
// flip must already see the overlay. // flip must already see the overlay.
// //
// Overlays on a page kept alive by a KeepAlive (App.vue / ToolPage.vue) are // An overlay on a kept-alive page counts only while that page is on screen, and
// asked to close when that page is left: their portal sits in <body>, outside // is closed when the page is left: its <body> portal would outlive it.
// the cached page, so an open one would otherwise stay on screen — scroll lock
// and all — over the next page. Only an overlay on the page on screen counts.
import { getCurrentInstance, getCurrentScope, onDeactivated, onScopeDispose, toValue, watch } from 'vue'; import { getCurrentInstance, getCurrentScope, onDeactivated, onScopeDispose, toValue, watch } from 'vue';
import { openOverlay, closeOverlay } from '@/utils/shortcut.js'; import { openOverlay, closeOverlay } from '@/utils/shortcut.js';
+12 -31
View File
@@ -1,34 +1,16 @@
// "Is my page the one on screen?" for components living under the app's // "Is my page the one on screen?" for components under the app's KeepAlive
// <KeepAlive> layers (App.vue caches Home and ToolPage; ToolPage caches each // layers (App.vue caches Home and ToolPage; ToolPage caches each tool). A
// visited tool). A cached page stays mounted while another route shows, so // cached page stays mounted while another route shows, so whatever acts on the
// anything that acts on the route, the document or window-level events must // route, the document or window events pauses while it is deactivated.
// pause while it is deactivated and catch up when it is activated again. // onActivated / onDeactivated fire for every cached ancestor, so the same call
// // works at a page root and deep inside it. Outside a component, always active.
// Vue registers onActivated / onDeactivated on every KeepAlive-cached
// ancestor, so the same call works in a page root (Home), deep inside it
// (Nav) and inside a cached tool (ToolPage's inner KeepAlive).
//
// useRouteActive() → ref, true while the calling component's page is
// active; always true outside a component instance
// or a KeepAlive.
// useActiveValue(getter, { pathOf }) → computed that follows `getter`
// while active and holds its last value while hidden,
// so a watcher on it ignores what other pages do to
// shared state (the route's query) and fires on
// activation only if the value really changed meanwhile.
// useActiveEventListener(target, event, handler, options)
// → the listener is attached only while active
// (mounted / activated) and detached on deactivation
// and unmount.
import { import {
computed, getCurrentInstance, onActivated, onBeforeUnmount, onDeactivated, onMounted, ref, computed, getCurrentInstance, onActivated, onBeforeUnmount, onDeactivated, onMounted, ref,
} from 'vue'; } from 'vue';
// A component can first mount inside a branch that is already cached away // An async child can first mount after its page was cached away; it starts
// (an async child whose chunk lands after its page was left); it starts // inactive. `isDeactivated` is the flag KeepAlive sets on the cached root.
// inactive and waits for the next activation. `isDeactivated` is the flag
// Vue's KeepAlive sets on the cached root.
const inDeactivatedBranch = (instance) => { const inDeactivatedBranch = (instance) => {
for (let current = instance; current; current = current.parent) { for (let current = instance; current; current = current.parent) {
if (current.isDeactivated) return true; if (current.isDeactivated) return true;
@@ -45,11 +27,9 @@ export const useRouteActive = () => {
return active; return active;
}; };
// `pathOf` (optional) reads the current location's path. A navigation updates // A computed that follows `getter` while active and holds its last value while
// the route before the old page is deactivated (that happens after the next // hidden. The route changes a render before the old page is deactivated, so
// render), so `active` alone would let the next page's query through for one // `pathOf` (the current path) also holds it once the path leaves the page's.
// flush; with `pathOf` the value also holds once the path leaves the one the
// page was activated on. `active` is injectable for tests.
export const useActiveValue = (getter, { pathOf, active = useRouteActive() } = {}) => { export const useActiveValue = (getter, { pathOf, active = useRouteActive() } = {}) => {
let ownPath = pathOf?.(); let ownPath = pathOf?.();
if (getCurrentInstance()) onActivated(() => { ownPath = pathOf?.(); }); if (getCurrentInstance()) onActivated(() => { ownPath = pathOf?.(); });
@@ -58,6 +38,7 @@ export const useActiveValue = (getter, { pathOf, active = useRouteActive() } = {
)); ));
}; };
// Attached only while active (mounted / activated).
export const useActiveEventListener = (target, event, handler, options) => { export const useActiveEventListener = (target, event, handler, options) => {
const active = useRouteActive(); const active = useRouteActive();
let attached = false; let attached = false;
+1 -2
View File
@@ -1,8 +1,7 @@
// Scroll tracking: when page scrolls // Scroll tracking: when page scrolls
// 1) capture the first partially visible section, write to store.changeSection() (for Nav highlighting) // 1) capture the first partially visible section, write to store.changeSection() (for Nav highlighting)
// 2) send one JNScroll event when each section first fully enters the viewport (only once, deduplicated) // 2) send one JNScroll event when each section first fully enters the viewport (only once, deduplicated)
// The listener runs only while the homepage is the page on screen (it stays // Listens only while the (kept-alive) homepage is on screen.
// alive in a KeepAlive while a tool page shows).
import { useActiveEventListener } from './use-route-active.js'; import { useActiveEventListener } from './use-route-active.js';
import { useMainStore } from '@/store'; import { useMainStore } from '@/store';
+2 -7
View File
@@ -18,9 +18,6 @@
// through advancedToolsRef.openTool(slug); // through advancedToolsRef.openTool(slug);
// test-running keys dispatch commands on utils/app-commands.js // test-running keys dispatch commands on utils/app-commands.js
// - `h` key infoMask switch only executes when isInfosLoaded is true // - `h` key infoMask switch only executes when isInfosLoaded is true
// - the map is live only while Home is the page on screen: Home stays alive
// in App.vue's KeepAlive, so it is cleared on deactivation and rebuilt on
// activation (use-route-active.js).
// - every entry here is a home-page action. Overlays (Dialog / Sheet / // - every entry here is a home-page action. Overlays (Dialog / Sheet /
// Drawer) suspend the whole map while they are open — see // Drawer) suspend the whole map while they are open — see
// utils/shortcut.js — so nothing needs a per-key "is something covering // utils/shortcut.js — so nothing needs a per-key "is something covering
@@ -53,7 +50,6 @@ const buildShortcutConfig = ({ refs, store, t, configs, userPreferences }) => {
toggleInfoMask, toggleInfoMask,
} = refs; } = refs;
// Tool keys open the tool's page (Advanced.vue's openTool pushes the route).
const goToAdvancedTool = (slug, trackName) => { const goToAdvancedTool = (slug, trackName) => {
advancedToolsRef.value.openTool(slug); advancedToolsRef.value.openTool(slug);
trackEvent('Nav', 'NavClick', trackName); trackEvent('Nav', 'NavClick', trackName);
@@ -276,9 +272,8 @@ export const useShortcuts = ({ refs, store, t, configs, userPreferences }) => {
// Suspending the map behind an overlay is the primitives' job // Suspending the map behind an overlay is the primitives' job
// (composables/use-overlay-shortcuts.js), so nothing is wired here. // (composables/use-overlay-shortcuts.js), so nothing is wired here.
// //
// Home is the only route that registers shortcuts; drop them when it is // Home is the only route that registers shortcuts; the map is cleared
// left (cached away or unmounted), so keystrokes on another page can't // when it is left (cached away or unmounted) and rebuilt on its return.
// drive the hidden homepage. Coming back rebuilds the map.
let disposed = false; let disposed = false;
let loaded = false; let loaded = false;
const active = useRouteActive(); const active = useRouteActive();
+5 -10
View File
@@ -1,8 +1,5 @@
// Single source of truth for the Advanced Tools. // Single source of truth for the Advanced Tools: their /tools/:slug pages
// // (ToolPage.vue), the card groups, the Nav, the keyboard shortcuts and the
// This one registry drives every place a tool is referenced: its page at
// /tools/:slug (ToolPage.vue), the Advanced.vue card groups, the nav menu
// and mobile nav sheet, the keyboard shortcuts (use-shortcuts.js) and the
// legacy `/?tool=<slug>` redirect (utils/legacy-tool-link.js). // legacy `/?tool=<slug>` redirect (utils/legacy-tool-link.js).
// //
// Entry shape: // Entry shape:
@@ -16,9 +13,8 @@
// requiresConfig — gate: only shown when this `/api/configs` flag is // requiresConfig — gate: only shown when this `/api/configs` flag is
// true (e.g. 'cloudFlare' for the Radar-backed tools) // true (e.g. 'cloudFlare' for the Radar-backed tools)
// //
// Gates decide listing only (card grid, nav), via // Gates decide listing only (utils/tool-availability.js); a /tools/:slug link
// isToolAvailable() in utils/tool-availability.js; a /tools/:slug link opens // opens the tool regardless.
// the tool regardless.
export const ADVANCED_TOOLS = [ export const ADVANCED_TOOLS = [
{ slug: 'pingtest', emoji: '⏱️', titleKey: 'pingtest.Title', noteKey: 'advancedtools.PingTestNote', category: 'network', component: () => import('@/components/advanced-tools/GlobalLatencyTest.vue') }, { slug: 'pingtest', emoji: '⏱️', titleKey: 'pingtest.Title', noteKey: 'advancedtools.PingTestNote', category: 'network', component: () => import('@/components/advanced-tools/GlobalLatencyTest.vue') },
@@ -39,8 +35,7 @@ export const ADVANCED_TOOLS = [
{ slug: 'personacheck', emoji: '🎭', titleKey: 'personacheck.Title', noteKey: 'advancedtools.PersonaCheck', category: 'deep', component: () => import('@/components/advanced-tools/PersonaCheck.vue'), requiresOriginalSite: true }, { slug: 'personacheck', emoji: '🎭', titleKey: 'personacheck.Title', noteKey: 'advancedtools.PersonaCheck', category: 'deep', component: () => import('@/components/advanced-tools/PersonaCheck.vue'), requiresOriginalSite: true },
]; ];
// Fast slug → entry lookup (the tool page and the legacy redirect resolve a // slug → entry.
// tool by slug).
export const TOOL_BY_SLUG = new Map(ADVANCED_TOOLS.map((tool) => [tool.slug, tool])); export const TOOL_BY_SLUG = new Map(ADVANCED_TOOLS.map((tool) => [tool.slug, tool]));
// Card groups, in render order. On a self-hosted deployment the 'deep' tools // Card groups, in render order. On a self-hosted deployment the 'deep' tools
+1 -4
View File
@@ -8,10 +8,7 @@ const env = import.meta.env ?? {};
const isFireBaseSet = !!env.VITE_FIREBASE_API_KEY && !!env.VITE_FIREBASE_AUTH_DOMAIN && !!env.VITE_FIREBASE_PROJECT_ID; const isFireBaseSet = !!env.VITE_FIREBASE_API_KEY && !!env.VITE_FIREBASE_AUTH_DOMAIN && !!env.VITE_FIREBASE_PROJECT_ID;
// Inputs for utils/auth-domain.js. Browsers sign in through the shared // Inputs for utils/auth-domain.js, read on demand (display mode needs `window`).
// default domain; the installed PWA may use VITE_FIREBASE_PWA_AUTH_DOMAIN
// (the site's own host, whose /__/auth proxy keeps the redirect handler
// same-origin). Read on demand: the display mode needs `window`.
const authDomainInputs = () => ({ const authDomainInputs = () => ({
runningAsPwa: isRunningAsPwa(), runningAsPwa: isRunningAsPwa(),
authDomain: env.VITE_FIREBASE_AUTH_DOMAIN, authDomain: env.VITE_FIREBASE_AUTH_DOMAIN,
+2 -12
View File
@@ -10,11 +10,6 @@ import { installAnimationRevival } from '@/utils/revive-animations.js';
// cards, nav, shortcuts, in-app links — routes here. // cards, nav, shortcuts, in-app links — routes here.
// /privacy → the privacy policy. // /privacy → the privacy policy.
// /r/:id → read-only shared diagnostic report (KV-backed, noindex). // /r/:id → read-only shared diagnostic report (KV-backed, noindex).
// App.vue keeps the homepage and the tool page alive across navigation, so
// back / forward between them is plain history that restores both as left.
// The drawer-era `/?tool=<slug>` links redirect to /tools/<slug>
// (utils/legacy-tool-link.js). Page changes slide / fade as View Transitions
// (utils/page-transition.js).
// //
// Home is imported eagerly (it's the default landing); everything else is // Home is imported eagerly (it's the default landing); everything else is
// lazy so it stays out of the homepage bundle. // lazy so it stays out of the homepage bundle.
@@ -43,17 +38,12 @@ const router = createRouter({
}, },
}); });
// `/?tool=<slug>` → /tools/<slug> (or `/` for an unknown slug); a page load // Legacy `/?tool=<slug>` links → /tools/<slug>, replacing the history entry.
// replaces the entry, so the legacy URL leaves no trace in history. null lets
// every other navigation through.
router.beforeEach((to) => resolveLegacyToolLink(to) ?? undefined); router.beforeEach((to) => resolveLegacyToolLink(to) ?? undefined);
// Page changes animate as View Transitions (utils/page-transition.js). Hooked // Hooked here, not in a component, so they exist before the first navigation
// here, not in a component, so the guards exist before the first navigation
// and outlive any page. // and outlive any page.
installPageTransitions(router); installPageTransitions(router);
// History steps leave the page's running CSS animations frozen on iOS once
// the browser's own gesture animation lands — see utils/revive-animations.js.
installAnimationRevival(router); installAnimationRevival(router);
export default router; export default router;
+7 -16
View File
@@ -3,8 +3,7 @@ import { defineStore } from 'pinia';
import { loadFirebaseAuth, authDomainInputs } from './firebase-init.js'; import { loadFirebaseAuth, authDomainInputs } from './firebase-init.js';
import { signInProvidersFor } from './utils/auth-domain.js'; import { signInProvidersFor } from './utils/auth-domain.js';
import { writeAuthHint } from './utils/auth-hint.js'; import { writeAuthHint } from './utils/auth-hint.js';
import { resolveSignInFlow, shouldFallBackToRedirect } from './utils/auth-flow.js'; import { markPendingRedirectSignIn, shouldFallBackToRedirect, takePendingRedirectSignIn } from './utils/auth-redirect.js';
import { markPendingRedirectSignIn, takePendingRedirectSignIn } from './utils/auth-redirect.js';
import { isRunningAsPwa } from './utils/pwa.js'; import { isRunningAsPwa } from './utils/pwa.js';
import i18n from './locales/i18n.js'; import i18n from './locales/i18n.js';
import { createInitialAchievementsState } from './data/achievements.js'; import { createInitialAchievementsState } from './data/achievements.js';
@@ -278,24 +277,18 @@ export const useMainStore = defineStore('main', {
async signInWithGithub() { async signInWithGithub() {
await this.signInWithProvider('github'); await this.signInWithProvider('github');
}, },
// Shared sign-in path for both buttons (flow choice: utils/auth-flow.js). // Shared sign-in path for both buttons: popup in a browser tab, then
// Popup in a browser tab: set the user, then reload. Redirect in the // reload; redirect in the installed PWA or when the popup can't open
// installed PWA, or when the popup can't open: mark the pending return // (utils/auth-redirect.js), finished at boot by completeRedirectSignIn.
// and leave the page — completeRedirectSignIn picks it up at boot.
async signInWithProvider(providerKey) { async signInWithProvider(providerKey) {
if (!this.signInProviders.includes(providerKey)) {
console.warn(`Sign-in with ${providerKey} is not offered here`);
return;
}
const descriptor = SIGN_IN_PROVIDERS[providerKey]; const descriptor = SIGN_IN_PROVIDERS[providerKey];
try { try {
const fb = await loadFirebaseAuth(); const fb = await loadFirebaseAuth();
if (resolveSignInFlow({ runningAsPwa: isRunningAsPwa() }) === 'popup') { if (!isRunningAsPwa()) {
try { try {
const result = await fb.signInWithPopup(fb.auth, descriptor.build(fb)); const result = await fb.signInWithPopup(fb.auth, descriptor.build(fb));
this.user = result.user; this.user = result.user;
writeAuthHint(true); writeAuthHint(true);
// refresh browser after successful login
window.location.reload(); window.location.reload();
return; return;
} catch (error) { } catch (error) {
@@ -309,10 +302,8 @@ export const useMainStore = defineStore('main', {
this.handleSignInError(error, descriptor); this.handleSignInError(error, descriptor);
} }
}, },
// Boot-time half of the redirect flow: consumes the pending marker and // Boot half of the redirect flow: true when it signed the visitor in
// reads the provider's result. Resolves true when it signed the visitor // (state set before mount, no reload).
// in (state set before mount, no reload); false for no marker, no
// Firebase, a visitor who backed out at the provider, or an error.
async completeRedirectSignIn() { async completeRedirectSignIn() {
const pending = takePendingRedirectSignIn(); const pending = takePendingRedirectSignIn();
if (!pending) return false; if (!pending) return false;
+5 -14
View File
@@ -270,15 +270,10 @@ footer {
max-height: calc(100dvh - var(--jn-gb-anchor) - 3.25rem - env(safe-area-inset-top) - 1rem) !important; max-height: calc(100dvh - var(--jn-gb-anchor) - 3.25rem - env(safe-area-inset-top) - 1rem) !important;
} }
/* Page transitions (utils/page-transition.js) — View Transitions keyed by /* Page transitions (utils/page-transition.js), keyed by <html
<html data-page-transition>: "forward" (Home → a page) slides the new page data-page-transition>: "forward" slides the new page in from the right,
in from the right over the old one, which drifts left and dims; "back" is "back" mirrors it, "fade" cross-fades. The Nav is captured on its own and
the mirror, the old page sliding out right over the returning one; "fade" shown in its new state at once, so only the page moves. */
(page → page) cross-fades. The Nav is captured on its own and shown in
its new state at once, so only the page below moves; slides blend
normally (no plus-lighter brightening where the two pages overlap). The
pseudo-elements hang off <html> itself, hence no space before
`::view-transition-*`. */
.jn-site-nav { .jn-site-nav {
view-transition-name: site-nav; view-transition-name: site-nav;
} }
@@ -288,17 +283,13 @@ footer {
::view-transition-new(site-nav) { ::view-transition-new(site-nav) {
animation: none; animation: none;
} }
html:not([data-page-transition])::view-transition-old(root),
html:not([data-page-transition])::view-transition-new(root) {
animation: none;
}
@media (prefers-reduced-motion: no-preference) { @media (prefers-reduced-motion: no-preference) {
html[data-page-transition="forward"]::view-transition-old(root), html[data-page-transition="forward"]::view-transition-old(root),
html[data-page-transition="forward"]::view-transition-new(root), html[data-page-transition="forward"]::view-transition-new(root),
html[data-page-transition="back"]::view-transition-old(root), html[data-page-transition="back"]::view-transition-old(root),
html[data-page-transition="back"]::view-transition-new(root) { html[data-page-transition="back"]::view-transition-new(root) {
mix-blend-mode: normal; mix-blend-mode: normal; /* no plus-lighter brightening where the pages overlap */
animation-duration: 260ms; animation-duration: 260ms;
animation-timing-function: cubic-bezier(0.32, 0.72, 0, 1); animation-timing-function: cubic-bezier(0.32, 0.72, 0, 1);
animation-fill-mode: both; animation-fill-mode: both;
+4 -7
View File
@@ -1,11 +1,8 @@
// Which Firebase auth domain this session signs in through, and which // Which Firebase auth domain this session signs in through, and which
// providers that leaves usable. // providers that leaves: browsers keep the shared default, the installed PWA
// The default domain (VITE_FIREBASE_AUTH_DOMAIN) is a login host shared with // may use VITE_FIREBASE_PWA_AUTH_DOMAIN (why: api/AGENTS.md "Firebase Auth
// other services, so browsers keep it. The installed PWA needs the auth // handler proxy"). GitHub's single callback URL is on the default domain, so a
// handler on the site's own origin (served by the /__/auth proxy) for the // PWA on another domain offers only Google.
// redirect result to survive, so it may use VITE_FIREBASE_PWA_AUTH_DOMAIN
// instead. GitHub's OAuth App allows a single callback URL, on the default
// domain — a PWA on a different domain can only offer Google.
const ALL_PROVIDERS = ['google', 'github']; const ALL_PROVIDERS = ['google', 'github'];
const DEFAULT_DOMAIN_ONLY = new Set(['github']); const DEFAULT_DOMAIN_ONLY = new Set(['github']);
-17
View File
@@ -1,17 +0,0 @@
// Sign-in flow choice: popup or full-page redirect.
// An installed PWA (iOS standalone above all) has no popup with a usable
// `opener`, so the provider's result could never be posted back — it signs
// in by redirect instead, consumed at boot via utils/auth-redirect.js. A
// browser tab keeps the popup and falls back to the redirect only when the
// popup itself can't open.
// Popup rejections that mean "this environment can't do popups", as opposed
// to the visitor closing or superseding one.
const REDIRECT_FALLBACK_CODES = new Set([
'auth/popup-blocked',
'auth/operation-not-supported-in-this-environment',
]);
export const resolveSignInFlow = ({ runningAsPwa }) => (runningAsPwa ? 'redirect' : 'popup');
export const shouldFallBackToRedirect = (errorCode) => REDIRECT_FALLBACK_CODES.has(errorCode);
+13 -2
View File
@@ -1,10 +1,21 @@
// Pending-redirect sign-in marker: set right before signInWithRedirect leaves // Redirect sign-in: when it replaces the popup, and the pending marker boot
// the page, consumed at boot so only a return from the provider pays for // uses to finish it. The installed PWA has no popup with a usable `opener`, so
// it always redirects; a browser tab falls back to it only when the popup
// can't open. The marker is set right before signInWithRedirect leaves the
// page and consumed at boot, so only a return from the provider pays for
// loading Firebase and calling getRedirectResult. sessionStorage-scoped — the // loading Firebase and calling getRedirectResult. sessionStorage-scoped — the
// redirect comes back into the same tab / PWA webview. Value = provider key // redirect comes back into the same tab / PWA webview. Value = provider key
// ('google' / 'github'), for the error copy. // ('google' / 'github'), for the error copy.
const PENDING_REDIRECT_KEY = 'pendingRedirectSignIn'; const PENDING_REDIRECT_KEY = 'pendingRedirectSignIn';
// Popup rejections meaning "no popups here", not the visitor closing one.
const REDIRECT_FALLBACK_CODES = new Set([
'auth/popup-blocked',
'auth/operation-not-supported-in-this-environment',
]);
export const shouldFallBackToRedirect = (errorCode) => REDIRECT_FALLBACK_CODES.has(errorCode);
const defaultStorage = () => { const defaultStorage = () => {
try { try {
return globalThis.sessionStorage ?? null; return globalThis.sessionStorage ?? null;
+3 -9
View File
@@ -1,12 +1,6 @@
// Where the page breadcrumb's "Home" crumb goes. vue-router keeps the // Where the page breadcrumb's "Home" crumb goes: 'back' when vue-router's
// previous in-app location in `history.state.back` (null when the page was // `history.state.back` is the homepage (stepping back restores the kept-alive
// opened directly, from a bookmark or another site). When that entry is the // page and its scroll), otherwise '/' (a push), so the crumb always lands home.
// homepage, stepping back in history restores it as it was left — the kept-
// alive page and its scroll position; anything else (no in-app entry, or
// another page such as a different tool) pushes `/` so the crumb always
// lands where its label says.
//
// resolveBackTarget(window.history.state) → 'back' | '/'
const HOME_PATH = '/'; const HOME_PATH = '/';
+6 -15
View File
@@ -1,18 +1,9 @@
// Legacy Advanced Tools links: tools once opened as a homepage drawer driven // Legacy `/?tool=<slug>` links (the drawer era; still in bookmarks, shares and
// by `/?tool=<slug>`, and those links live on in bookmarks, shares and search // search results) → the tool's page, via the router's beforeEach. A known slug
// results. The router's beforeEach (router/index.js) sends them to the tool's // lands on /tools/<slug>, an unknown or malformed one on the homepage; other
// page. The redirect keeps the navigation's own mode: a page load (the router's // query params and the hash ride along. Returns null when there is nothing to
// first navigation) and a history step replace the entry, so back never // rewrite. The redirect keeps the navigation's mode: a page load or history
// returns to the old URL; an in-app push stays a push, keeping the page it // step replaces the entry, an in-app push stays a push.
// left in history.
//
// resolveLegacyToolLink({ path, query, hash })
// → null nothing to rewrite
// → { path, query, hash? }
//
// Only the homepage carries the param. A known slug lands on /tools/<slug>;
// an unknown (or malformed: repeated, empty) one lands on the homepage. Every
// other query param (e.g. `q`, utm tags) and the hash ride along.
import { TOOL_BY_SLUG } from '../data/tools.js'; import { TOOL_BY_SLUG } from '../data/tools.js';
+5 -15
View File
@@ -1,18 +1,7 @@
// What a click on a site-navigation entry does on the current route. The Nav // What a click on a site-navigation entry (Nav, mobile nav sheet, breadcrumb)
// (every route), the mobile nav sheet and the page breadcrumb share it: // does on the current route: `{ tool }` → { kind: 'tool', slug }; `{ section }`
// // → 'scroll' on Home, 'home-then-scroll' elsewhere; anything else → null.
// resolveNavTarget({ item: { section: 'Connectivity' }, routeName: 'home' }) // composables/use-nav-target.js carries it out.
// → { kind: 'scroll', section } — scroll the homepage
// resolveNavTarget({ item: { section: 'Connectivity' }, routeName: 'tool' })
// → { kind: 'home-then-scroll', section } — go home, then scroll
// resolveNavTarget({ item: { tool: 'whois' }, routeName })
// → { kind: 'tool', slug: 'whois' } — open the tool's page
//
// Anything else resolves to null. composables/use-nav-target.js carries the
// result out.
//
// isPlainClick(event) — false for a click the browser should keep (modified
// or non-primary: new tab / window), so link handlers let those through.
export const resolveNavTarget = ({ item, routeName } = {}) => { export const resolveNavTarget = ({ item, routeName } = {}) => {
if (typeof item?.tool === 'string' && item.tool) return { kind: 'tool', slug: item.tool }; if (typeof item?.tool === 'string' && item.tool) return { kind: 'tool', slug: item.tool };
@@ -23,6 +12,7 @@ export const resolveNavTarget = ({ item, routeName } = {}) => {
return null; return null;
}; };
// False for a click the browser should keep (modified or non-primary: new tab / window).
export const isPlainClick = (event) => !( export const isPlainClick = (event) => !(
event.defaultPrevented || event.button !== 0 event.defaultPrevented || event.button !== 0
|| event.metaKey || event.ctrlKey || event.shiftKey || event.altKey || event.metaKey || event.ctrlKey || event.shiftKey || event.altKey
+10 -26
View File
@@ -1,29 +1,17 @@
// Page transitions: route changes between pages animate like native // Page transitions: route changes animate through the same-document View
// navigation through the same-document View Transitions API. The browser // Transitions API, keyed by the direction set on <html data-page-transition>
// snapshots the old page, the router swaps the DOM (scroll restoration // (keyframes in style/style.css). The router swaps the DOM, scroll included,
// included) inside the snapshot window, then the two snapshots animate — so // inside the snapshot window, so pages of different heights never overlap live.
// pages of different heights and scroll positions never overlap live.
//
// transitionDirection(from, to) → null | 'forward' | 'back' | 'fade'
// shouldAnimate({ direction, supported, reducedMotion, inFlight, uaTransition })
// installPageTransitions(router) — registered once in router/index.js
//
// The direction lands on <html data-page-transition>, which style/style.css
// keys its keyframes on; no direction, no support or reduced motion is the
// plain instant switch.
import { nextTick as vueNextTick } from 'vue'; import { nextTick as vueNextTick } from 'vue';
const HOME = 'home'; const HOME = 'home';
const REDUCED_MOTION_QUERY = '(prefers-reduced-motion: reduce)'; const REDUCED_MOTION_QUERY = '(prefers-reduced-motion: reduce)';
// Upper bound on how long the router may hold the DOM update once the old // Cap on how long the router may hold the DOM update once the old page is snapshotted.
// page is snapshotted; past it the transition finishes whatever its state.
export const UPDATE_TIMEOUT_MS = 1000; export const UPDATE_TIMEOUT_MS = 1000;
// Which animation a navigation gets. Nothing animates on the first navigation // Nothing animates on the first navigation (the router's START_LOCATION) or a
// (the router's START_LOCATION: no name, no matched records) or when only the // query / hash-only change (a tool writing back its `?q=`).
// query / hash changes (a tool writing back its `?q=`). Home → a page slides
// forward, a page → Home slides back, page → page cross-fades.
export const transitionDirection = (from, to) => { export const transitionDirection = (from, to) => {
if (!from || !to) return null; if (!from || !to) return null;
const firstNavigation = from.name === undefined const firstNavigation = from.name === undefined
@@ -34,10 +22,8 @@ export const transitionDirection = (from, to) => {
return 'fade'; return 'fade';
}; };
// Whether a navigation starts a view transition. A running one is never // A running transition is never stacked on, and a history step the browser
// stacked on (the caller skips it instead), and a history step the browser // already animated (iOS swipe-back: `hasUAVisualTransition`) is not animated twice.
// already animated itself (iOS swipe-back: `hasUAVisualTransition`) is not
// animated twice.
export const shouldAnimate = ({ direction, supported, reducedMotion, inFlight, uaTransition }) => export const shouldAnimate = ({ direction, supported, reducedMotion, inFlight, uaTransition }) =>
Boolean(direction) && supported === true && !reducedMotion && !inFlight && !uaTransition; Boolean(direction) && supported === true && !reducedMotion && !inFlight && !uaTransition;
@@ -57,8 +43,7 @@ export const installPageTransitions = (router, {
let endUpdate = null; // resolves the active transition's update callback let endUpdate = null; // resolves the active transition's update callback
let uaTransition = false; // the pending history step was animated by the browser let uaTransition = false; // the pending history step was animated by the browser
// Capture phase on the target: runs before the router's own popstate // Capture phase: runs before the router's own popstate listener.
// listener starts the navigation.
win?.addEventListener?.('popstate', (event) => { win?.addEventListener?.('popstate', (event) => {
uaTransition = event?.hasUAVisualTransition === true; uaTransition = event?.hasUAVisualTransition === true;
}, { capture: true }); }, { capture: true });
@@ -105,7 +90,6 @@ export const installPageTransitions = (router, {
// `ready` rejects when the animation is skipped; nothing to report. // `ready` rejects when the animation is skipped; nothing to report.
transition.ready?.catch?.(() => {}); transition.ready?.catch?.(() => {});
Promise.resolve(transition.finished).catch(() => {}).finally(() => { Promise.resolve(transition.finished).catch(() => {}).finally(() => {
if (active !== transition) return;
active = null; active = null;
delete root.dataset.pageTransition; delete root.dataset.pageTransition;
}); });
+7 -24
View File
@@ -1,23 +1,9 @@
// Revives the page's running CSS animations after a history step. // Revives the page's running CSS animations after a history step. On iOS
// // (measured on 26), animations running before a swipe-back stay visually frozen
// On iOS, accelerated CSS animations that were running before a swipe-back // after it until their layer is rebuilt: cancel + play does it, ancestor style
// gesture stay visually frozen once the gesture lands on the previous page: // changes don't. Each is replayed at the time it was at, so nothing jumps.
// requestAnimationFrame keeps its rate and the animations' own time keeps
// advancing, yet the compositor paints no new frame for them until their
// layer is rebuilt (measured on iOS 26: cancel + play, a display:none round
// trip or a re-insert revive them; style changes on an ancestor don't).
// A navigation that commits a new animation (a push with its page
// transition) revives them as a side effect, so only history steps — where
// the browser's own gesture animation replaces ours — are affected.
//
// reviveAnimations(doc) → how many animations were restarted
// installAnimationRevival(router) — registered once in router/index.js
//
// Each running CSS animation is cancelled, replayed and set back to the time
// it was at, so the revival shows no jump; passes run a couple of frames
// after the navigation settles and again shortly after, in case the browser
// dismisses its gesture snapshot late.
// Late passes: the browser may dismiss its gesture snapshot late.
const REVIVE_DELAYS_MS = [0, 400, 1200]; const REVIVE_DELAYS_MS = [0, 400, 1200];
const isCssAnimation = (animation) => typeof animation?.animationName === 'string'; const isCssAnimation = (animation) => typeof animation?.animationName === 'string';
@@ -36,9 +22,7 @@ export const reviveAnimations = (doc = globalThis.document) => {
return revived; return revived;
}; };
// `win` / `doc` / `schedule` are injectable for tests; `schedule(fn, delayMs)` // `schedule` defaults to two animation frames (the new DOM is on screen) plus the delay.
// defaults to two animation frames (the navigation's DOM is on screen) plus
// the delay.
export const installAnimationRevival = (router, { export const installAnimationRevival = (router, {
win = globalThis.window, win = globalThis.window,
doc = globalThis.document, doc = globalThis.document,
@@ -50,8 +34,7 @@ export const installAnimationRevival = (router, {
} = {}) => { } = {}) => {
if (!win?.addEventListener || !router?.afterEach) return; if (!win?.addEventListener || !router?.afterEach) return;
let historyStep = false; let historyStep = false;
// Capture phase: runs before the router's own popstate listener starts // Capture phase: runs before the router's own popstate listener.
// the navigation this flag belongs to.
win.addEventListener('popstate', () => { historyStep = true; }, { capture: true }); win.addEventListener('popstate', () => { historyStep = true; }, { capture: true });
router.afterEach(() => { router.afterEach(() => {
if (!historyStep) return; if (!historyStep) return;
+2 -4
View File
@@ -1,8 +1,6 @@
// Smooth scroll to specified element (supports passing element or id string). // Smooth scroll to specified element (supports passing element or id string).
//
// SECTION_SCROLL_OFFSET is the one offset every homepage section scroll uses // Every homepage section scroll (nav, breadcrumb, shortcuts): the fixed Nav (h-14) plus air.
// (nav links, breadcrumb, keyboard shortcuts): room for the fixed Nav (h-14)
// plus a little air, so all of them land a section at the same spot.
export const SECTION_SCROLL_OFFSET = 70; export const SECTION_SCROLL_OFFSET = 70;
export function scrollToElement(el, offset = 0) { export function scrollToElement(el, offset = 0) {
+3 -5
View File
@@ -1,11 +1,9 @@
// Deployment gates for the Advanced Tools registry (data/tools.js): decides // Deployment gates for the Advanced Tools registry (data/tools.js): decides
// whether a tool is listed in the card grid and the nav. A // whether a tool is listed in the card grid and the nav. A tool can require
// tool can require the original site (`requiresOriginalSite`) and/or one // the original site (`requiresOriginalSite`) and/or one `/api/configs` flag
// `/api/configs` flag (`requiresConfig`). Configs arrive asynchronously (`{}` until the fetch // (`requiresConfig`). Configs arrive asynchronously (`{}` until the fetch
// lands), so a gated tool reads as unavailable until then. A /tools/:slug // lands), so a gated tool reads as unavailable until then. A /tools/:slug
// link is not gated. // link is not gated.
//
// listedTools() applies the gates to a whole registry for a menu.
export const isToolAvailable = (tool, configs) => { export const isToolAvailable = (tool, configs) => {
if (!tool) return false; if (!tool) return false;
+3 -6
View File
@@ -1,8 +1,5 @@
// Tool-page cache policy: ToolPage keeps every visited tool alive in a // Tool-page cache policy: cached tools can hold one account's results, so
// KeepAlive, and a tool's state can hold one account's results (quota hints, // ToolPage drops them on sign-out or an account switch; a first sign-in
// signed-in test runs). When the signed-in account goes away — sign-out or a // keeps them, as nothing there belonged to anyone else.
// switch to another account — the cache is dropped so the next viewer never
// sees the previous account's results. A first sign-in keeps it: nothing in
// there belonged to anyone else.
export const shouldDropToolCache = (prevUid, nextUid) => Boolean(prevUid) && prevUid !== nextUid; export const shouldDropToolCache = (prevUid, nextUid) => Boolean(prevUid) && prevUid !== nextUid;
+5 -9
View File
@@ -11,10 +11,8 @@
<meta name="author" content="Jason Ng" /> <meta name="author" content="Jason Ng" />
<meta name="mobile-web-app-capable" content="yes" /> <meta name="mobile-web-app-capable" content="yes" />
<!-- No apple-mobile-web-app-status-bar-style: the default keeps the iOS <!-- No apple-mobile-web-app-status-bar-style: the default keeps the iOS
status bar as opaque system chrome above the web view. A translucent status bar opaque system chrome. A translucent one lets the page run
one lets the page run under the status bar, and since iOS 26 the system under it, and since iOS 26 its scroll-edge blur then covers the Nav. -->
then draws its Liquid Glass scroll-edge blur over the top of the
installed app, across the Nav. -->
<meta name="theme-color" content="#ffffff" media="(prefers-color-scheme: light)" /> <meta name="theme-color" content="#ffffff" media="(prefers-color-scheme: light)" />
<meta name="theme-color" content="#0e0e0e" media="(prefers-color-scheme: dark)" /> <meta name="theme-color" content="#0e0e0e" media="(prefers-color-scheme: dark)" />
<meta name="background-color" content="#ffffff" /> <meta name="background-color" content="#ffffff" />
@@ -114,11 +112,9 @@
padding: 0; padding: 0;
} }
/* Reserve space at the top of the body for: (a) the iOS safe-area /* Room for the safe-area inset and the fixed Nav (3.5rem = h-14 in
inset (the Nav sits below it), and (b) the fixed Nav that App.vue Nav.vue), which takes no flow height so it stays put while the body
renders above every route (3.5rem = h-14 in Nav.vue). Being `fixed`, rubber-bands on iOS. */
the Nav stays put while the body rubber-bands on iOS, and takes no
flow height of its own. */
body { body {
padding-top: calc(env(safe-area-inset-top) + 3.5rem); padding-top: calc(env(safe-area-inset-top) + 3.5rem);
} }
-35
View File
@@ -1,35 +0,0 @@
// Tests for the sign-in flow choice (frontend/utils/auth-flow.js): popup in a
// browser tab, redirect in the installed PWA and on a popup that can't open.
import assert from 'node:assert/strict';
import { describe, it } from 'node:test';
import { resolveSignInFlow, shouldFallBackToRedirect } from '../frontend/utils/auth-flow.js';
describe('resolveSignInFlow', () => {
it('redirects inside the installed PWA', () => {
assert.equal(resolveSignInFlow({ runningAsPwa: true }), 'redirect');
});
it('uses the popup in a browser tab', () => {
assert.equal(resolveSignInFlow({ runningAsPwa: false }), 'popup');
});
});
describe('shouldFallBackToRedirect', () => {
it('falls back when the popup cannot open', () => {
assert.equal(shouldFallBackToRedirect('auth/popup-blocked'), true);
assert.equal(shouldFallBackToRedirect('auth/operation-not-supported-in-this-environment'), true);
});
it('does not fall back when the visitor closed or superseded the popup', () => {
assert.equal(shouldFallBackToRedirect('auth/popup-closed-by-user'), false);
assert.equal(shouldFallBackToRedirect('auth/cancelled-popup-request'), false);
});
it('does not fall back on other or missing codes', () => {
assert.equal(shouldFallBackToRedirect('auth/account-exists-with-different-credential'), false);
assert.equal(shouldFallBackToRedirect('auth/network-request-failed'), false);
assert.equal(shouldFallBackToRedirect(undefined), false);
assert.equal(shouldFallBackToRedirect(''), false);
});
});
+17 -7
View File
@@ -1,9 +1,10 @@
// Tests for the pending-redirect sign-in marker (frontend/utils/auth-redirect.js), // Tests for frontend/utils/auth-redirect.js: the popup → redirect fallback
// driven through an injected fake sessionStorage. // codes, and the pending-redirect marker driven through a fake sessionStorage.
import assert from 'node:assert/strict'; import assert from 'node:assert/strict';
import { describe, it, beforeEach } from 'node:test'; import { describe, it, beforeEach } from 'node:test';
import { import {
shouldFallBackToRedirect,
markPendingRedirectSignIn, markPendingRedirectSignIn,
hasPendingRedirectSignIn, hasPendingRedirectSignIn,
takePendingRedirectSignIn, takePendingRedirectSignIn,
@@ -22,17 +23,26 @@ beforeEach(() => {
storage = createStorage(); storage = createStorage();
}); });
describe('shouldFallBackToRedirect', () => {
it('falls back when the popup cannot open', () => {
assert.equal(shouldFallBackToRedirect('auth/popup-blocked'), true);
assert.equal(shouldFallBackToRedirect('auth/operation-not-supported-in-this-environment'), true);
});
it('does not fall back when the visitor closed the popup, or on other codes', () => {
assert.equal(shouldFallBackToRedirect('auth/popup-closed-by-user'), false);
assert.equal(shouldFallBackToRedirect('auth/cancelled-popup-request'), false);
assert.equal(shouldFallBackToRedirect('auth/network-request-failed'), false);
assert.equal(shouldFallBackToRedirect(undefined), false);
});
});
describe('auth-redirect marker', () => { describe('auth-redirect marker', () => {
it('reports nothing pending on a fresh storage', () => { it('reports nothing pending on a fresh storage', () => {
assert.equal(hasPendingRedirectSignIn(storage), false); assert.equal(hasPendingRedirectSignIn(storage), false);
assert.equal(takePendingRedirectSignIn(storage), null); assert.equal(takePendingRedirectSignIn(storage), null);
}); });
it('marks a pending redirect with its provider', () => {
markPendingRedirectSignIn('github', storage);
assert.equal(hasPendingRedirectSignIn(storage), true);
});
it('has() reads without consuming', () => { it('has() reads without consuming', () => {
markPendingRedirectSignIn('google', storage); markPendingRedirectSignIn('google', storage);
hasPendingRedirectSignIn(storage); hasPendingRedirectSignIn(storage);
+1 -3
View File
@@ -1,6 +1,4 @@
// resolveBackTarget() — where the page breadcrumb's "Home" crumb goes: // resolveBackTarget() — where the page breadcrumb's "Home" crumb goes.
// a history step back when the previous in-app entry is the homepage, a push
// of `/` otherwise (direct visit, another page before this one).
import assert from 'node:assert/strict'; import assert from 'node:assert/strict';
import { describe, it } from 'node:test'; import { describe, it } from 'node:test';
+2 -6
View File
@@ -1,9 +1,5 @@
// useNavTarget() — carries out a site-navigation click. With an injected // useNavTarget() with an injected route and router: the section scroll must
// route and router: the order of going home and scrolling (the router's own // land after the router's own scroll (next tick) and a frame.
// scroll to the top of a pushed page runs after the next tick, so the section
// scroll must come after that tick and a frame), the plain scroll on the
// homepage, tool pages, a refused navigation scrolling nothing, and the
// re-aiming while a freshly mounted homepage resizes.
import assert from 'node:assert/strict'; import assert from 'node:assert/strict';
import { describe, it, beforeEach, afterEach } from 'node:test'; import { describe, it, beforeEach, afterEach } from 'node:test';
+2 -4
View File
@@ -1,7 +1,5 @@
// use-route-active.js — the KeepAlive-awareness helpers. Activation itself // use-route-active.js: activation itself needs a mounted tree; here the
// needs a mounted component tree (covered in the browser); here: the // outside-a-component fallback and useActiveValue with an injected `active`.
// outside-a-component fallback and useActiveValue's hold-while-hidden rule
// with an injected `active` ref.
import assert from 'node:assert/strict'; import assert from 'node:assert/strict';
import { describe, it } from 'node:test'; import { describe, it } from 'node:test';
+20 -26
View File
@@ -10,8 +10,8 @@ import { useActiveValue } from '../frontend/composables/use-route-active.js';
const source = readFileSync(new URL('../frontend/components/advanced-tools/IpCalculator.vue', import.meta.url), 'utf8'); const source = readFileSync(new URL('../frontend/components/advanced-tools/IpCalculator.vue', import.meta.url), 'utf8');
const script = parse(source).descriptor.scriptSetup.content.replace(/^import[\s\S]*?;$/gm, ''); const script = parse(source).descriptor.scriptSetup.content.replace(/^import[\s\S]*?;$/gm, '');
// `active` stands in for the page's KeepAlive state (use-route-active.js). // `active` stands in for the page's KeepAlive state (use-route-active.js).
const fixture = (q, drawer = false, active = ref(true)) => { const fixture = (q, active = ref(true)) => {
const route = reactive({ path: '/tools/ipcalculator', query: { q, hl: 'en', ...(drawer ? { tool: 'ipcalculator' } : {}) } }); const route = reactive({ path: '/tools/ipcalculator', query: { q, hl: 'en' } });
const replacements = []; const replacements = [];
const bindings = { const bindings = {
computed, ref, watch, analyzeCidr, calculate, computed, ref, watch, analyzeCidr, calculate,
@@ -35,24 +35,22 @@ for (const [input, prefix, expected] of [
['2001:db8::1/64', 128, '2001:db8::1/128'], ['2001:db8::1/64', 128, '2001:db8::1/128'],
]) { ]) {
test(`prefix commit preserves the host in the share URL: ${input}`, () => { test(`prefix commit preserves the host in the share URL: ${input}`, () => {
for (const drawer of [false, true]) { const run = fixture(input);
const run = fixture(input, drawer); const split = run.result.value.analysis.split;
const split = run.result.value.analysis.split; run.picked.value = input;
run.picked.value = input; run.onPrefix(prefix);
run.onPrefix(prefix); assert.equal(run.query.value, input, 'dragging leaves the input and URL untouched');
assert.equal(run.query.value, input, 'dragging leaves the input and URL untouched'); assert.equal(run.replacements.length, 0);
assert.equal(run.replacements.length, 0); assert.equal(run.result.value.analysis.split, split, 'dragging preserves the subnet list');
assert.equal(run.result.value.analysis.split, split, 'dragging preserves the subnet list'); run.onPrefixCommit(prefix);
run.onPrefixCommit(prefix); assert.equal(run.query.value, expected);
assert.equal(run.query.value, expected); assert.deepEqual(run.route.query, { q: expected, hl: 'en' });
assert.deepEqual(run.route.query, { q: expected, hl: 'en', ...(drawer ? { tool: 'ipcalculator' } : {}) }); assert.equal(run.replacements.length, 1);
assert.equal(run.replacements.length, 1); assert.equal(run.picked.value, '');
assert.equal(run.picked.value, ''); assert.deepEqual(calculate(run.route.query.q).analysis.cidr, run.result.value.analysis.cidr);
assert.deepEqual(calculate(run.route.query.q).analysis.cidr, run.result.value.analysis.cidr); assert.equal(run.result.value.analysis.split, run.result.value.analysis.cidr);
assert.equal(run.result.value.analysis.split, run.result.value.analysis.cidr); run.onPrefixCommit(prefix);
run.onPrefixCommit(prefix); assert.equal(run.replacements.length, 1, 'an unchanged prefix does not replace the URL again');
assert.equal(run.replacements.length, 1, 'an unchanged prefix does not replace the URL again');
}
}); });
} }
@@ -67,25 +65,22 @@ test('a prefix commit without an address result leaves the URL alone', () => {
test('?q= runs on mount, follows the route while active, and is ignored while cached away', async () => { test('?q= runs on mount, follows the route while active, and is ignored while cached away', async () => {
const active = ref(true); const active = ref(true);
const run = fixture('10.0.0.0/22', false, active); const run = fixture('10.0.0.0/22', active);
assert.equal(run.query.value, '10.0.0.0/22'); assert.equal(run.query.value, '10.0.0.0/22');
assert.equal(run.result.value.analysis.cidr.prefix, 22); assert.equal(run.result.value.analysis.cidr.prefix, 22);
// Navigating to another tool changes the route before this one is // The route changes before this page is deactivated; the path tells them apart.
// deactivated: the path already tells it the query isn't its own.
run.route.path = '/tools/asn'; run.route.path = '/tools/asn';
run.route.query = { q: 'AS13335' }; run.route.query = { q: 'AS13335' };
await nextTick(); await nextTick();
assert.equal(run.query.value, '10.0.0.0/22', 'another path\'s ?q= is ignored'); assert.equal(run.query.value, '10.0.0.0/22', 'another path\'s ?q= is ignored');
// Another page rewrites ?q= while the calculator is cached.
active.value = false; active.value = false;
run.route.query = { q: 'AS15169' }; run.route.query = { q: 'AS15169' };
await nextTick(); await nextTick();
assert.equal(run.query.value, '10.0.0.0/22', 'a hidden tool ignores the route'); assert.equal(run.query.value, '10.0.0.0/22', 'a hidden tool ignores the route');
assert.equal(run.result.value.analysis.cidr.prefix, 22); assert.equal(run.result.value.analysis.cidr.prefix, 22);
// Coming back to the same URL keeps an unsubmitted draft.
run.route.path = '/tools/ipcalculator'; run.route.path = '/tools/ipcalculator';
run.route.query = { q: '10.0.0.0/22' }; run.route.query = { q: '10.0.0.0/22' };
run.query.value = '192.0.2.'; run.query.value = '192.0.2.';
@@ -93,7 +88,6 @@ test('?q= runs on mount, follows the route while active, and is ignored while ca
await nextTick(); await nextTick();
assert.equal(run.query.value, '192.0.2.', 'an unchanged ?q= does not overwrite the input'); assert.equal(run.query.value, '192.0.2.', 'an unchanged ?q= does not overwrite the input');
// A different ?q= on arrival runs.
active.value = false; active.value = false;
run.route.query = { q: '192.0.2.0/24' }; run.route.query = { q: '192.0.2.0/24' };
active.value = true; active.value = true;
+1 -3
View File
@@ -1,6 +1,4 @@
// resolveNavTarget() — what a site-navigation click does on the current route // resolveNavTarget() and isPlainClick() (utils/nav-target.js).
// (Nav, mobile nav sheet, page breadcrumb) — and isPlainClick(), which lets a
// link's modified / non-primary clicks through to the browser.
import assert from 'node:assert/strict'; import assert from 'node:assert/strict';
import { describe, it } from 'node:test'; import { describe, it } from 'node:test';
+3 -6
View File
@@ -1,6 +1,5 @@
// Page transitions: the direction a navigation animates in, the decision to // Spec for utils/page-transition.js, the router hook-up driven by a fake
// animate at all, and the router hook-up driven by a fake router and a fake // router and View Transitions document.
// View Transitions document.
import assert from 'node:assert/strict'; import assert from 'node:assert/strict';
import { describe, it } from 'node:test'; import { describe, it } from 'node:test';
@@ -68,9 +67,7 @@ describe('shouldAnimate()', () => {
}); });
}); });
// A router stand-in recording the hooks, and a document whose // startViewTransition runs the update on the next task, as a browser does after the snapshot.
// startViewTransition runs the update callback on the next task like a browser
// does after snapshotting the old page.
const setup = ({ supported = true, reducedMotion = false, timeoutMs = 1000 } = {}) => { const setup = ({ supported = true, reducedMotion = false, timeoutMs = 1000 } = {}) => {
const hooks = { beforeResolve: null, afterEach: null, onError: null }; const hooks = { beforeResolve: null, afterEach: null, onError: null };
const router = { const router = {
-1
View File
@@ -62,5 +62,4 @@ describe('installAnimationRevival()', () => {
hooks.afterEach(); hooks.afterEach();
assert.equal(scheduled.length, 3, 'the flag is consumed'); assert.equal(scheduled.length, 3, 'the flag is consumed');
}); });
}); });
+4 -12
View File
@@ -1,6 +1,6 @@
// Tests for frontend/utils/tool-availability.js — the listing gate the // Tests for frontend/utils/tool-availability.js — the listing gate the
// Advanced Tools card grid and nav filter through — plus the gate // Advanced Tools card grid and nav filter through — plus the gate fields the
// fields the registry in frontend/data/tools.js declares. // registry in frontend/data/tools.js declares.
import assert from 'node:assert/strict'; import assert from 'node:assert/strict';
import { describe, it } from 'node:test'; import { describe, it } from 'node:test';
@@ -46,14 +46,6 @@ describe('isToolAvailable', () => {
assert.equal(isToolAvailable(null, { cloudFlare: true }), false); assert.equal(isToolAvailable(null, { cloudFlare: true }), false);
assert.equal(isToolAvailable(undefined, {}), false); assert.equal(isToolAvailable(undefined, {}), false);
}); });
it('filters a list in registry order', () => {
const tools = [PUBLIC, ORIGINAL, RADAR, BOTH];
const listed = (configs) => tools.filter((tool) => isToolAvailable(tool, configs)).map((tool) => tool.slug);
assert.deepEqual(listed({}), ['public']);
assert.deepEqual(listed({ originalSite: false, cloudFlare: true }), ['public', 'radar']);
assert.deepEqual(listed({ originalSite: true, cloudFlare: true }), ['public', 'original', 'radar', 'both']);
});
}); });
describe('listedTools', () => { describe('listedTools', () => {
@@ -63,13 +55,13 @@ describe('listedTools', () => {
it('applies the gates in registry order', () => { it('applies the gates in registry order', () => {
assert.deepEqual(slugs(listedTools(tools, {})), ['public']); assert.deepEqual(slugs(listedTools(tools, {})), ['public']);
assert.deepEqual(slugs(listedTools(tools, { originalSite: true })), ['public', 'original']); assert.deepEqual(slugs(listedTools(tools, { originalSite: true })), ['public', 'original']);
assert.deepEqual(slugs(listedTools(tools, { originalSite: false, cloudFlare: true })), ['public', 'radar']);
assert.deepEqual(slugs(listedTools(tools, { originalSite: true, cloudFlare: true })), assert.deepEqual(slugs(listedTools(tools, { originalSite: true, cloudFlare: true })),
['public', 'original', 'radar', 'both']); ['public', 'original', 'radar', 'both']);
}); });
it('lists every registered tool, Persona Check included, once all gates pass', () => { it('lists every registered tool once all gates pass', () => {
const configs = { originalSite: true, cloudFlare: true }; const configs = { originalSite: true, cloudFlare: true };
assert.ok(slugs(listedTools(ADVANCED_TOOLS, configs)).includes('personacheck'));
assert.equal(listedTools(ADVANCED_TOOLS, configs).length, ADVANCED_TOOLS.length); assert.equal(listedTools(ADVANCED_TOOLS, configs).length, ADVANCED_TOOLS.length);
}); });
}); });
+2 -3
View File
@@ -268,10 +268,9 @@ export default defineConfig({
port: frontEndPort, port: frontEndPort,
proxy: { proxy: {
'/api': `http://localhost:${backEndPort}`, '/api': `http://localhost:${backEndPort}`,
// Firebase Auth handler on the dev origin, as frontend-server.js does in // Firebase Auth handler, as frontend-server.js serves it in production
// production; only with VITE_FIREBASE_PROJECT_ID set
...(firebaseAuthTarget && { ...(firebaseAuthTarget && {
[FIREBASE_AUTH_PROXY_PATH]: { target: firebaseAuthTarget, changeOrigin: true, secure: true }, [FIREBASE_AUTH_PROXY_PATH]: { target: firebaseAuthTarget, changeOrigin: true },
}), }),
}, },
allowedHosts: ['dev.ipcheck.ing', 'test.ipcheck.ing'], allowedHosts: ['dev.ipcheck.ing', 'test.ipcheck.ing'],