From 96525d2fc7d18bc2b6783064531b4315241e92f4 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 5 Oct 2026 16:50:16 +0000 Subject: [PATCH] =?UTF-8?q?Chore:=20trim=20the=20branch=20=E2=80=94=20left?= =?UTF-8?q?overs,=20redundancy,=20comments?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 Claude-Session: https://claude.ai/code/session_013GbLTGMZ45MV78qd2tHoco --- .env.example | 19 +++---- api/AGENTS.md | 16 +++--- common/firebase-auth-proxy.js | 15 ++---- frontend-server.js | 11 ++-- frontend/AGENTS.md | 53 ++++++++----------- frontend/App.vue | 25 ++++----- frontend/components/Advanced.vue | 16 +++--- frontend/components/Home.vue | 13 ++--- frontend/components/Nav.vue | 36 +++++-------- frontend/components/PageBreadcrumb.vue | 16 ++---- frontend/components/PrivacyPolicy.vue | 1 - frontend/components/ToolPage.vue | 27 ++++------ frontend/components/User.vue | 14 ++--- frontend/components/UserMenu.vue | 16 ++---- .../components/advanced-tools/AsnProfile.vue | 6 +-- .../advanced-tools/EnhancedDnsLeakTest.vue | 7 +-- .../advanced-tools/GlobalLatencyTest.vue | 9 ++-- .../advanced-tools/InvisibilityTest.vue | 7 +-- .../components/advanced-tools/IpBlocklist.vue | 13 ++--- .../advanced-tools/IpCalculator.vue | 13 ++--- .../components/advanced-tools/MtrTest.vue | 9 ++-- .../advanced-tools/PersonaCheck.vue | 14 ++--- .../advanced-tools/SecurityChecklist.vue | 6 +-- .../advanced-tools/ServiceStatus.vue | 11 ++-- frontend/components/ip-infos/ASNInfo.vue | 12 ++--- .../components/ip-infos/IpDetailPanel.vue | 6 +-- frontend/components/widgets/Preferences.vue | 3 +- frontend/components/widgets/Toast.vue | 3 +- frontend/composables/use-document-meta.js | 9 ++-- frontend/composables/use-nav-target.js | 17 ++---- frontend/composables/use-overlay-shortcuts.js | 6 +-- frontend/composables/use-route-active.js | 43 +++++---------- frontend/composables/use-section-tracking.js | 3 +- frontend/composables/use-shortcuts.js | 9 +--- frontend/data/tools.js | 15 ++---- frontend/firebase-init.js | 5 +- frontend/router/index.js | 14 +---- frontend/store.js | 23 +++----- frontend/style/style.css | 19 ++----- frontend/utils/auth-domain.js | 11 ++-- frontend/utils/auth-flow.js | 17 ------ frontend/utils/auth-redirect.js | 15 +++++- frontend/utils/back-target.js | 12 ++--- frontend/utils/legacy-tool-link.js | 21 +++----- frontend/utils/nav-target.js | 20 ++----- frontend/utils/page-transition.js | 36 ++++--------- frontend/utils/revive-animations.js | 31 +++-------- frontend/utils/scroll-to.js | 6 +-- frontend/utils/tool-availability.js | 8 ++- frontend/utils/tool-cache.js | 9 ++-- index.html | 14 ++--- tests/auth-flow.test.js | 35 ------------ tests/auth-redirect.test.js | 24 ++++++--- tests/back-target.test.js | 4 +- tests/composable-nav-target.test.js | 8 +-- tests/composable-route-active.test.js | 6 +-- tests/ip-calculator-query.test.js | 46 +++++++--------- tests/nav-target.test.js | 4 +- tests/page-transition.test.js | 9 ++-- tests/revive-animations.test.js | 1 - tests/tool-availability.test.js | 16 ++---- vite.config.js | 5 +- 62 files changed, 293 insertions(+), 625 deletions(-) delete mode 100644 frontend/utils/auth-flow.js delete mode 100644 tests/auth-flow.test.js diff --git a/.env.example b/.env.example index 018949ec..8b448972 100644 --- a/.env.example +++ b/.env.example @@ -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:///__/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:///__/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:///__/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:///__/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="" diff --git a/api/AGENTS.md b/api/AGENTS.md index b81ea343..1ce37c0a 100644 --- a/api/AGENTS.md +++ b/api/AGENTS.md @@ -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 `.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 `.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 diff --git a/common/firebase-auth-proxy.js b/common/firebase-auth-proxy.js index d34a70f6..15813e47 100644 --- a/common/firebase-auth-proxy.js +++ b/common/firebase-auth-proxy.js @@ -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 -// `.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 → +// `.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; diff --git a/frontend-server.js b/frontend-server.js index 55570b11..6b137255 100644 --- a/frontend-server.js +++ b/frontend-server.js @@ -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 .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({ diff --git a/frontend/AGENTS.md b/frontend/AGENTS.md index 684e9508..ec8a1dc3 100644 --- a/frontend/AGENTS.md +++ b/frontend/AGENTS.md @@ -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 plain click `router.push`es, modifier clicks stay the - browser's), Nav, shortcuts (`Advanced.vue`'s exposed - `openTool`), in-app links. `/?tool=` 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 plain click + `router.push`es, modified clicks stay the browser's), Nav, shortcuts + (`Advanced.vue`'s exposed `openTool`), in-app links. `/?tool=` is only + a replace-redirect in the router's `beforeEach` (`utils/legacy-tool-link.js`). - Gates (`requiresOriginalSite`, `requiresConfig: ''`, 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/?q=`, which a legacy - `/?tool=&q=` redirects to with `q` kept. + it on every run (AsnProfile); the URL is `/tools/?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 `` 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 `` above the `

` (← 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 ``, never pulse-dots. diff --git a/frontend/App.vue b/frontend/App.vue index 40e7a492..31b678a5 100644 --- a/frontend/App.vue +++ b/frontend/App.vue @@ -1,21 +1,17 @@ diff --git a/frontend/composables/use-document-meta.js b/frontend/composables/use-document-meta.js index 12df9531..9cbc1a91 100644 --- a/frontend/composables/use-document-meta.js +++ b/frontend/composables/use-document-meta.js @@ -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') || '', diff --git a/frontend/composables/use-nav-target.js b/frontend/composables/use-nav-target.js index 7ff985be..82a5f1ee 100644 --- a/frontend/composables/use-nav-target.js +++ b/frontend/composables/use-nav-target.js @@ -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'; diff --git a/frontend/composables/use-overlay-shortcuts.js b/frontend/composables/use-overlay-shortcuts.js index 0c40f9f1..8a825d3b 100644 --- a/frontend/composables/use-overlay-shortcuts.js +++ b/frontend/composables/use-overlay-shortcuts.js @@ -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 , 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 portal would outlive it. import { getCurrentInstance, getCurrentScope, onDeactivated, onScopeDispose, toValue, watch } from 'vue'; import { openOverlay, closeOverlay } from '@/utils/shortcut.js'; diff --git a/frontend/composables/use-route-active.js b/frontend/composables/use-route-active.js index f8a18615..a70da732 100644 --- a/frontend/composables/use-route-active.js +++ b/frontend/composables/use-route-active.js @@ -1,34 +1,16 @@ -// "Is my page the one on screen?" for components living under the app's -// 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; diff --git a/frontend/composables/use-section-tracking.js b/frontend/composables/use-section-tracking.js index 6e5b49c0..a9238981 100644 --- a/frontend/composables/use-section-tracking.js +++ b/frontend/composables/use-section-tracking.js @@ -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'; diff --git a/frontend/composables/use-shortcuts.js b/frontend/composables/use-shortcuts.js index 88690053..aece07c4 100644 --- a/frontend/composables/use-shortcuts.js +++ b/frontend/composables/use-shortcuts.js @@ -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(); diff --git a/frontend/data/tools.js b/frontend/data/tools.js index 118c4464..9c6831f8 100644 --- a/frontend/data/tools.js +++ b/frontend/data/tools.js @@ -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=` 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 diff --git a/frontend/firebase-init.js b/frontend/firebase-init.js index 4c2aa0bf..76d856bc 100644 --- a/frontend/firebase-init.js +++ b/frontend/firebase-init.js @@ -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, diff --git a/frontend/router/index.js b/frontend/router/index.js index 18c8828d..63b415bc 100644 --- a/frontend/router/index.js +++ b/frontend/router/index.js @@ -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=` links redirect to /tools/ -// (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=` → /tools/ (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=` links → /tools/, 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; diff --git a/frontend/store.js b/frontend/store.js index 128be4c4..7ba20098 100644 --- a/frontend/store.js +++ b/frontend/store.js @@ -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; diff --git a/frontend/style/style.css b/frontend/style/style.css index 52c9ec7f..9a17eb66 100644 --- a/frontend/style/style.css +++ b/frontend/style/style.css @@ -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 - : "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 itself, hence no space before - `::view-transition-*`. */ +/* Page transitions (utils/page-transition.js), keyed by : "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; diff --git a/frontend/utils/auth-domain.js b/frontend/utils/auth-domain.js index 3c7dc6cc..3e6b3caa 100644 --- a/frontend/utils/auth-domain.js +++ b/frontend/utils/auth-domain.js @@ -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']); diff --git a/frontend/utils/auth-flow.js b/frontend/utils/auth-flow.js deleted file mode 100644 index 7fc654e2..00000000 --- a/frontend/utils/auth-flow.js +++ /dev/null @@ -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); diff --git a/frontend/utils/auth-redirect.js b/frontend/utils/auth-redirect.js index 7d2c8ebb..7a50ccb5 100644 --- a/frontend/utils/auth-redirect.js +++ b/frontend/utils/auth-redirect.js @@ -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; diff --git a/frontend/utils/back-target.js b/frontend/utils/back-target.js index b891ceb4..bd2cde2d 100644 --- a/frontend/utils/back-target.js +++ b/frontend/utils/back-target.js @@ -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 = '/'; diff --git a/frontend/utils/legacy-tool-link.js b/frontend/utils/legacy-tool-link.js index 01d579aa..9d927f74 100644 --- a/frontend/utils/legacy-tool-link.js +++ b/frontend/utils/legacy-tool-link.js @@ -1,18 +1,9 @@ -// Legacy Advanced Tools links: tools once opened as a homepage drawer driven -// by `/?tool=`, 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/; -// 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=` 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/, 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'; diff --git a/frontend/utils/nav-target.js b/frontend/utils/nav-target.js index 7047410d..010cf5c9 100644 --- a/frontend/utils/nav-target.js +++ b/frontend/utils/nav-target.js @@ -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 diff --git a/frontend/utils/page-transition.js b/frontend/utils/page-transition.js index c31d0178..f00dd760 100644 --- a/frontend/utils/page-transition.js +++ b/frontend/utils/page-transition.js @@ -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 , 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 +// (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; }); diff --git a/frontend/utils/revive-animations.js b/frontend/utils/revive-animations.js index f1d63d2a..fb329620 100644 --- a/frontend/utils/revive-animations.js +++ b/frontend/utils/revive-animations.js @@ -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; diff --git a/frontend/utils/scroll-to.js b/frontend/utils/scroll-to.js index 3b8eda20..86bfcb08 100644 --- a/frontend/utils/scroll-to.js +++ b/frontend/utils/scroll-to.js @@ -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) { diff --git a/frontend/utils/tool-availability.js b/frontend/utils/tool-availability.js index b9ef826e..7bda5b75 100644 --- a/frontend/utils/tool-availability.js +++ b/frontend/utils/tool-availability.js @@ -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; diff --git a/frontend/utils/tool-cache.js b/frontend/utils/tool-cache.js index 80de80d3..70a5a7bf 100644 --- a/frontend/utils/tool-cache.js +++ b/frontend/utils/tool-cache.js @@ -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; diff --git a/index.html b/index.html index 10a50032..f291a630 100644 --- a/index.html +++ b/index.html @@ -11,10 +11,8 @@ + 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. --> @@ -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); } diff --git a/tests/auth-flow.test.js b/tests/auth-flow.test.js deleted file mode 100644 index 175ff7de..00000000 --- a/tests/auth-flow.test.js +++ /dev/null @@ -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); - }); -}); diff --git a/tests/auth-redirect.test.js b/tests/auth-redirect.test.js index ba9f3f31..692ea863 100644 --- a/tests/auth-redirect.test.js +++ b/tests/auth-redirect.test.js @@ -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); diff --git a/tests/back-target.test.js b/tests/back-target.test.js index 649b8478..db9cfa80 100644 --- a/tests/back-target.test.js +++ b/tests/back-target.test.js @@ -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'; diff --git a/tests/composable-nav-target.test.js b/tests/composable-nav-target.test.js index 93e17ea9..117568fa 100644 --- a/tests/composable-nav-target.test.js +++ b/tests/composable-nav-target.test.js @@ -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'; diff --git a/tests/composable-route-active.test.js b/tests/composable-route-active.test.js index e9a59211..649885fc 100644 --- a/tests/composable-route-active.test.js +++ b/tests/composable-route-active.test.js @@ -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'; diff --git a/tests/ip-calculator-query.test.js b/tests/ip-calculator-query.test.js index 76ea65a2..633110ad 100644 --- a/tests/ip-calculator-query.test.js +++ b/tests/ip-calculator-query.test.js @@ -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; diff --git a/tests/nav-target.test.js b/tests/nav-target.test.js index 9cb1c080..be4cc2dc 100644 --- a/tests/nav-target.test.js +++ b/tests/nav-target.test.js @@ -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'; diff --git a/tests/page-transition.test.js b/tests/page-transition.test.js index 4ca5616d..88b849d1 100644 --- a/tests/page-transition.test.js +++ b/tests/page-transition.test.js @@ -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 = { diff --git a/tests/revive-animations.test.js b/tests/revive-animations.test.js index 3e9c38fd..1745ab44 100644 --- a/tests/revive-animations.test.js +++ b/tests/revive-animations.test.js @@ -62,5 +62,4 @@ describe('installAnimationRevival()', () => { hooks.afterEach(); assert.equal(scheduled.length, 3, 'the flag is consumed'); }); - }); diff --git a/tests/tool-availability.test.js b/tests/tool-availability.test.js index 593fb5a9..5d759735 100644 --- a/tests/tool-availability.test.js +++ b/tests/tool-availability.test.js @@ -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); }); }); diff --git a/vite.config.js b/vite.config.js index 5bdb7769..3b73cda3 100644 --- a/vite.config.js +++ b/vite.config.js @@ -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'],