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