From df91967f1b175b76408e4b8c45ffbe185d646227 Mon Sep 17 00:00:00 2001 From: jason5ng32 Date: Sun, 19 Apr 2026 17:14:51 +0800 Subject: [PATCH 01/57] Style(toast): move toasts to bottom-right, left of the FAB column MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Toast had been wedged into the bottom-*left* corner because the two floating action buttons (InfoMask + QueryIP) occupied the conventional bottom-right real estate. That was a downstream cost of the FAB choice and felt off — every app in the sonner/react-hot-toast generation puts toasts bottom-right by default. Keep the FABs where they are and move the toasts around them instead. The new rule lives in Toast.vue as an unscoped block (sonner renders its root to document.body, so scoped styles wouldn't reach it): right: calc(max(0px, (100vw - 1600px) / 2) + 66px) !important; The `(viewport - 1600) / 2` half-overflow term tracks the same content- edge math InfoMask / QueryIP use in their `positionStyle`, so the toast stays glued to the left side of the FAB column on every screen size: narrow (≤1600px): flat 66px from viewport right (FAB width 36 + gap 10 + FAB's own 20 right-margin = 66) wide (>1600px): 66px + half the 1600-clamped overflow, matching where the FAB column drifted inward Vertically the toast keeps sonner's default 32px offset — no longer climbing above the FABs, just side-stepping them. FAB vertical positions were nudged by 4px each (bottom-5 → bottom-6, bottom-[66px] → bottom-[70px]) for a touch more breathing room between them and the screen edge. Horizontal positioning unchanged, so the 66 in the CSS above still matches. Co-Authored-By: Claude Sonnet 4.5 --- frontend/components/widgets/InfoMask.vue | 2 +- frontend/components/widgets/QueryIP.vue | 2 +- frontend/components/widgets/Toast.vue | 35 ++++++++++++++++++++++-- 3 files changed, 34 insertions(+), 5 deletions(-) diff --git a/frontend/components/widgets/InfoMask.vue b/frontend/components/widgets/InfoMask.vue index d14b919d1..69da3e73e 100644 --- a/frontend/components/widgets/InfoMask.vue +++ b/frontend/components/widgets/InfoMask.vue @@ -3,7 +3,7 @@ diff --git a/frontend/components/widgets/Toast.vue b/frontend/components/widgets/Toast.vue index 90828b69b..ef93ed15f 100644 --- a/frontend/components/widgets/Toast.vue +++ b/frontend/components/widgets/Toast.vue @@ -1,7 +1,9 @@ + + + From 4fbb38c68f33712d31b9609d43f038d91702df1a Mon Sep 17 00:00:00 2001 From: jason5ng32 Date: Sun, 19 Apr 2026 18:18:03 +0800 Subject: [PATCH 02/57] Feat(connectivity): let users add custom availability tests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Network Connectivity section now ships with 7 built-in targets plus a user-editable slot. Users can add up to 9 custom tests (name + URL), each tested with the same favicon-load method as the built-ins and persisted to `userPreferences.customConnectivityTargets` in localStorage. Highlights: - The existing `connectivityTests` reactive array is the single source of truth; custom entries carry a `custom: true` flag and everything else (J/K navigation, `checkAllConnectivity`, status rendering) treats them identically to built-ins. - A watch on the stored preference does a diff-based reconciliation (drop removed ids, push new ids, leave survivors in place) so that adding or removing one target does NOT reset the status/time/mintime of the others — a cosmetic regression I hit in an earlier draft. - Custom cards render a first-letter colored tile instead of a lucide icon. Color is a stable hash of the name, so "Weibo" always shows the same shade across sessions / devices. - The "+" add tile sits at the end of the grid and hides itself once the 9-target cap is reached. - Inline × on hover removes a custom card. Built-in cards are not removable — the `v-if="test.custom"` gates the button. - URL normalization (`weibo.com` / `www.weibo.com` / full URL with path+query) collapses everything to `origin/favicon.ico?` so the existing `checkConnectivityHandler` can append its cache-bust suffix unchanged. - Add dialog uses a new shadcn-vue `Label` primitive (drop-in copy matching the library convention) for the two form fields. Errors toggle `aria-invalid` on the offending input for the red ring, and the error paragraph has a 1-line min-height reservation so toggling errors doesn't shift the dialog height. - `DEFAULT_PREFERENCES.customConnectivityTargets = []` plus a test update so shape assertions still match. - Four locales get the new `connectivity.addCustom.*` keys (title, labels, placeholders, hints, error strings, add/remove actions). Co-Authored-By: Claude Sonnet 4.5 --- frontend/components/ConnectivityTest.vue | 263 +++++++++++++++++++++-- frontend/components/ui/label/Label.vue | 33 +++ frontend/components/ui/label/index.js | 1 + frontend/data/default-preferences.js | 4 + frontend/locales/en.json | 17 +- frontend/locales/fr.json | 17 +- frontend/locales/tr.json | 17 +- frontend/locales/zh.json | 17 +- tests/default-preferences.test.js | 1 + 9 files changed, 346 insertions(+), 24 deletions(-) create mode 100644 frontend/components/ui/label/Label.vue create mode 100644 frontend/components/ui/label/index.js diff --git a/frontend/components/ConnectivityTest.vue b/frontend/components/ConnectivityTest.vue index e8213c31d..0b72973ea 100644 --- a/frontend/components/ConnectivityTest.vue +++ b/frontend/components/ConnectivityTest.vue @@ -4,12 +4,13 @@
-

+

🚦 {{ t('connectivity.Title') }}

- @@ -21,26 +22,39 @@
- + +
- + + + + {{ (test.name || '?').charAt(0).toUpperCase() }} + {{ test.name }}
- - - {{ test.status }} + + {{ test.status + }} @@ -49,22 +63,69 @@
+ + + + + + {{ t('connectivity.addCustom.AddCard') }} + +
+ + + + + +
+ +
+ + +
+ +
+ + +
+ +

{{ t('connectivity.addCustom.Hint') }}

+ +

{{ addError }}

+
+ +
+
+
+
+ + diff --git a/frontend/components/ui/label/index.js b/frontend/components/ui/label/index.js new file mode 100644 index 000000000..38eaa356d --- /dev/null +++ b/frontend/components/ui/label/index.js @@ -0,0 +1 @@ +export { default as Label } from "./Label.vue"; diff --git a/frontend/data/default-preferences.js b/frontend/data/default-preferences.js index e6a68bf43..9df57e0ba 100644 --- a/frontend/data/default-preferences.js +++ b/frontend/data/default-preferences.js @@ -13,6 +13,10 @@ export const DEFAULT_PREFERENCES = Object.freeze({ ipCardsToShow: 4, ipGeoSource: 0, lang: 'auto', // auto | zh | en | fr | tr + // User-defined extra targets for the Connectivity test grid. Each entry: + // { id: 'custom-', name: string, url: string-with-trailing-? } + // See ConnectivityTest.vue for how these are merged with the built-in list. + customConnectivityTargets: [], }); /** diff --git a/frontend/locales/en.json b/frontend/locales/en.json index af6304d63..0b7b43fd7 100644 --- a/frontend/locales/en.json +++ b/frontend/locales/en.json @@ -523,7 +523,22 @@ "StatusTimeout": "Timeout or Unavailable", "checking": "Checking...", "minTestTime": "Min Test Time: ", - "RefreshThisTest": "Refresh This Test" + "RefreshThisTest": "Refresh This Test", + "addCustom": { + "AddCard": "Add Test", + "Title": "Add a custom test", + "Hint": "We test by loading the site's favicon over HTTPS. Some sites may not be reachable this way. Custom tests are saved locally in your browser.", + "NameLabel": "Name", + "NamePlaceholder": "e.g. Wikipedia", + "UrlLabel": "URL", + "UrlPlaceholder": "e.g. wikipedia.org", + "Add": "Add", + "Remove": "Remove test", + "NameRequired": "Please enter a name.", + "UrlRequired": "Please enter a URL.", + "InvalidUrl": "That doesn't look like a valid URL.", + "LimitReached": "You've reached the 10 custom tests limit." + } }, "webrtc": { "id": "webrtc", diff --git a/frontend/locales/fr.json b/frontend/locales/fr.json index 534294e75..d74e05182 100644 --- a/frontend/locales/fr.json +++ b/frontend/locales/fr.json @@ -523,7 +523,22 @@ "StatusTimeout": "Délai dépassé", "checking": "Vérification en cours...", "minTestTime": "Minimal: ", - "RefreshThisTest": "Rafraîchir ce test" + "RefreshThisTest": "Rafraîchir ce test", + "addCustom": { + "AddCard": "Ajouter un test", + "Title": "Ajouter un test personnalisé", + "Hint": "Le test est effectué en chargeant le favicon du site en HTTPS. Certains sites peuvent ne pas être testables de cette manière. Les tests personnalisés sont enregistrés localement dans votre navigateur.", + "NameLabel": "Nom", + "NamePlaceholder": "ex. Wikipedia", + "UrlLabel": "URL", + "UrlPlaceholder": "ex. wikipedia.org", + "Add": "Ajouter", + "Remove": "Retirer le test", + "NameRequired": "Veuillez saisir un nom.", + "UrlRequired": "Veuillez saisir une URL.", + "InvalidUrl": "Cela ne ressemble pas à une URL valide.", + "LimitReached": "Limite de 10 tests personnalisés atteinte." + } }, "webrtc": { "id": "webrtc", diff --git a/frontend/locales/tr.json b/frontend/locales/tr.json index 1fbbd0793..784f69a35 100644 --- a/frontend/locales/tr.json +++ b/frontend/locales/tr.json @@ -522,7 +522,22 @@ "StatusTimeout": "Zaman Aşımı veya Kullanılamıyor", "checking": "Kontrol ediliyor...", "minTestTime": "Min Test Süresi: ", - "RefreshThisTest": "Bu Testi Yenile" + "RefreshThisTest": "Bu Testi Yenile", + "addCustom": { + "AddCard": "Test Ekle", + "Title": "Özel test ekle", + "Hint": "Test, sitenin favicon'u HTTPS üzerinden yüklenerek yapılır. Bazı siteler bu şekilde test edilemeyebilir. Özel testler tarayıcınızda kaydedilir.", + "NameLabel": "Ad", + "NamePlaceholder": "Örn. Wikipedia", + "UrlLabel": "URL", + "UrlPlaceholder": "Örn. wikipedia.org", + "Add": "Ekle", + "Remove": "Testi kaldır", + "NameRequired": "Lütfen bir ad girin.", + "UrlRequired": "Lütfen bir URL girin.", + "InvalidUrl": "Bu geçerli bir URL gibi görünmüyor.", + "LimitReached": "10 özel test sınırına ulaşıldı." + } }, "webrtc": { "id": "webrtc", diff --git a/frontend/locales/zh.json b/frontend/locales/zh.json index 0b4159ccd..aa0a80d8a 100644 --- a/frontend/locales/zh.json +++ b/frontend/locales/zh.json @@ -525,7 +525,22 @@ "StatusTimeout": "超时或不可用", "checking": "检查中...", "minTestTime": "最小测试时间:", - "RefreshThisTest": "刷新此测试" + "RefreshThisTest": "刷新此测试", + "addCustom": { + "AddCard": "添加测试", + "Title": "添加自定义测试", + "Hint": "通过 HTTPS 加载站点 favicon 进行测试,部分站点可能无法以这种方式测试。自定义测试会保存在浏览器本地。", + "NameLabel": "名称", + "NamePlaceholder": "例如:维基百科", + "UrlLabel": "URL", + "UrlPlaceholder": "例如:wikipedia.org", + "Add": "添加", + "Remove": "移除测试", + "NameRequired": "请输入名称", + "UrlRequired": "请输入 URL", + "InvalidUrl": "这看起来不是一个有效的 URL", + "LimitReached": "自定义测试已达到 10 个上限" + } }, "webrtc": { "id": "webrtc", diff --git a/tests/default-preferences.test.js b/tests/default-preferences.test.js index 915e438f0..c30badf25 100644 --- a/tests/default-preferences.test.js +++ b/tests/default-preferences.test.js @@ -22,6 +22,7 @@ describe('DEFAULT_PREFERENCES', () => { ipCardsToShow: 4, ipGeoSource: 0, lang: 'auto', + customConnectivityTargets: [], }); }); }); From c01e984159c9b2471fe2343e87526b52022419f5 Mon Sep 17 00:00:00 2001 From: jason5ng32 Date: Sun, 19 Apr 2026 18:18:17 +0800 Subject: [PATCH 03/57] Fix(a11y): silence Dialog description warning + extend aria-invalid to query inputs Two accessibility papercuts resolved together. 1. Every Dialog in the app printed a reka-ui warning in the console on open: Warning: Missing `Description` or `aria-describedby="undefined"` for DialogContent. The shadcn-vue DialogContent primitive already had a VisuallyHidden fallback for DialogTitle but none for DialogDescription, so screen readers had no aria-describedby target and reka-ui refused to stay quiet about it. Adding a symmetric `description` prop / slot plus a fallback that reuses the title string satisfies the contract without forcing every call site to pass redundant copy. Optional callers can still override via `:description="..."` or the `description` named slot when they want a distinct spoken label. 2. Propagate the aria-invalid-on-error pattern (introduced in the Connectivity Add dialog) to the other query inputs that have inline error messages: QueryIP, CensorshipCheck, DnsResolver, MacChecker, Whois. With shadcn-vue's built-in `aria-invalid:*` Tailwind utility, the offending field now gets the red ring automatically whenever its errorMsg / modalQueryError is non-empty, giving users a second, visual cue beyond the text message. Co-Authored-By: Claude Sonnet 4.5 --- .../components/advanced-tools/CensorshipCheck.vue | 2 +- .../components/advanced-tools/DnsResolver.vue | 2 +- frontend/components/advanced-tools/MacChecker.vue | 2 +- frontend/components/advanced-tools/Whois.vue | 2 +- frontend/components/ui/dialog/DialogContent.vue | 15 ++++++++++++++- frontend/components/widgets/QueryIP.vue | 2 +- 6 files changed, 19 insertions(+), 6 deletions(-) diff --git a/frontend/components/advanced-tools/CensorshipCheck.vue b/frontend/components/advanced-tools/CensorshipCheck.vue index 7c166cfed..f9ec52650 100644 --- a/frontend/components/advanced-tools/CensorshipCheck.vue +++ b/frontend/components/advanced-tools/CensorshipCheck.vue @@ -10,7 +10,7 @@ + v-model="queryURL" @keyup.enter="onSubmit" :aria-invalid="errorMsg !== ''" />
@@ -124,7 +124,7 @@ import { Input } from '@/components/ui/input'; import { Label } from '@/components/ui/label'; import { useStatusTone } from '@/composables/use-status-tone.js'; import { - ChevronRight, Chrome, Cloud, Compass, CirclePlus, Frown, Github, Meh, MessageCircle, + Play, Chrome, Cloud, Compass, CirclePlus, Frown, Github, Meh, MessageCircle, MessageSquareQuote, RotateCw, Smile, Store, X, Youtube, } from 'lucide-vue-next'; diff --git a/frontend/components/DnsLeaksTest.vue b/frontend/components/DnsLeaksTest.vue index c205f4c42..2ce94bc6a 100644 --- a/frontend/components/DnsLeaksTest.vue +++ b/frontend/components/DnsLeaksTest.vue @@ -12,7 +12,7 @@ @@ -99,7 +99,7 @@ import { Button } from '@/components/ui/button'; import { Card, CardContent } from '@/components/ui/card'; import getCountryName from '@/data/country-name.js'; import { useStatusTone } from '@/composables/use-status-tone.js'; -import { EthernetPort, ChevronRight, HeartPulse, MapPin, RotateCw } from 'lucide-vue-next'; +import { EthernetPort, Play, HeartPulse, MapPin, RotateCw } from 'lucide-vue-next'; import { Icon } from '@iconify/vue'; diff --git a/frontend/components/SpeedTest.vue b/frontend/components/SpeedTest.vue index 14a5a3a4e..d0edf17c2 100644 --- a/frontend/components/SpeedTest.vue +++ b/frontend/components/SpeedTest.vue @@ -23,7 +23,7 @@ - + @@ -162,7 +162,7 @@ import { Card, CardContent } from '@/components/ui/card'; import { Progress } from '@/components/ui/progress'; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'; import { - ArrowLeftRight, CalendarCheck2, ChevronRight, CloudDownload, CloudUpload, + ArrowLeftRight, CalendarCheck2, Play, CloudDownload, CloudUpload, Globe, Pause, PersonStanding, RotateCw, } from 'lucide-vue-next'; import { Icon } from '@iconify/vue'; @@ -219,7 +219,7 @@ const isError = computed(() => state.speedTest.status === 'error'); const ctaIcon = computed(() => { if (isRunning.value) return Pause; if (isFinished.value || isError.value) return RotateCw; - return ChevronRight; + return Play; }); diff --git a/frontend/components/WebRtcTest.vue b/frontend/components/WebRtcTest.vue index d15c0f118..45d192178 100644 --- a/frontend/components/WebRtcTest.vue +++ b/frontend/components/WebRtcTest.vue @@ -10,7 +10,7 @@ @@ -89,7 +89,7 @@ import { JnTooltip } from '@/components/ui/tooltip'; import { Button } from '@/components/ui/button'; import { Card, CardContent } from '@/components/ui/card'; import { useStatusTone } from '@/composables/use-status-tone.js'; -import { ChevronRight, MapPin, Flower, Network, RotateCw } from 'lucide-vue-next'; +import { Play, MapPin, Flower, Network, RotateCw } from 'lucide-vue-next'; import { Icon } from '@iconify/vue'; const { t } = useI18n(); From e78fa8b77bf1972fb9caf8e67b4148e54c04d752 Mon Sep 17 00:00:00 2001 From: jason5ng32 Date: Mon, 20 Apr 2026 10:17:25 +0800 Subject: [PATCH 18/57] Fix(forms): harden free-form inputs against iOS keyboard quirks MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - QueryIP: skip programmatic focus on iOS. Inside a fixed-position Dialog, iOS Safari cannot scroll a programmatically-focused input into view above the on-screen keyboard. Letting the user tap the input delegates the whole keyboard + visualViewport dance to iOS natives. Desktop still auto-focuses on open. - QueryIP: replace deprecated `navigator.platform` with a Macintosh UA + `maxTouchPoints` check so iPadOS 13+ is still detected. - Apply a consistent attribute set (`autocomplete="off" autocorrect="off" autocapitalize="off" spellcheck="false" data-1p-ignore data-lpignore`) to every free-form Input (QueryIP, Whois, MacChecker, CensorshipCheck, DnsResolver, ConnectivityTest × 2). iOS QuickType was pushing iCloud-address and password AutoFill onto URL/IP/MAC inputs. - Rename ipcheck.Placeholder from \"Please enter an IP address\" style to \"e.g. 1.1.1.1\" in all four locales. iOS address-AutoFill heuristics fire on the word \"address / 地址 / adresse / adresi\" even with `autocomplete=\"off\"`. - Document the six-attribute convention in frontend/AGENTS.md. Co-Authored-By: Claude Opus 4.7 (1M context) --- frontend/AGENTS.md | 6 +++++- frontend/components/ConnectivityTest.vue | 4 ++++ .../advanced-tools/CensorshipCheck.vue | 3 ++- .../components/advanced-tools/DnsResolver.vue | 4 +++- .../components/advanced-tools/MacChecker.vue | 2 +- frontend/components/advanced-tools/Whois.vue | 2 +- frontend/components/widgets/QueryIP.vue | 18 ++++++++++++++++-- frontend/locales/en.json | 2 +- frontend/locales/fr.json | 2 +- frontend/locales/tr.json | 2 +- frontend/locales/zh.json | 2 +- 11 files changed, 36 insertions(+), 11 deletions(-) diff --git a/frontend/AGENTS.md b/frontend/AGENTS.md index ce60ed3be..81cffd2d5 100644 --- a/frontend/AGENTS.md +++ b/frontend/AGENTS.md @@ -116,7 +116,9 @@ Rule: any new module that surfaces a status reuses these tones. Do not hand-writ ```vue
- +
``` +Every free-form Input that takes a URL / IP / MAC / domain / custom identifier carries the same six attributes shown above: `autocomplete="off"`, `autocorrect="off"`, `autocapitalize="off"`, `spellcheck="false"`, `data-1p-ignore`, `data-lpignore="true"`. iOS Safari's QuickType bar uses placeholder + nearby label text to offer address / email / password AutoFill — without these attributes it will push iCloud-address or password suggestions onto a plain IP/URL input. Keep placeholder copy free of "address / 地址 / adresse / adresi" style words where possible — iOS heuristics trigger on the word itself even with `autocomplete="off"`. + The `input-group` primitive (stock shadcn-vue, with `InputGroupInput` / `InputGroupAddon` / `InputGroupButton` / `InputGroupText` / `InputGroupTextarea` sub-parts) is available if you need a genuinely merged border / ring around a composite input — but the current convention above is what every consumer uses today. **Status card** — homepage status cards (Connectivity / WebRTC / DnsLeak / IPCard / RuleTest) use: diff --git a/frontend/components/ConnectivityTest.vue b/frontend/components/ConnectivityTest.vue index 2cd4ee645..ab86e931b 100644 --- a/frontend/components/ConnectivityTest.vue +++ b/frontend/components/ConnectivityTest.vue @@ -85,6 +85,8 @@ @@ -92,6 +94,8 @@ diff --git a/frontend/components/advanced-tools/CensorshipCheck.vue b/frontend/components/advanced-tools/CensorshipCheck.vue index 64ee8c19b..7ec4fe4f4 100644 --- a/frontend/components/advanced-tools/CensorshipCheck.vue +++ b/frontend/components/advanced-tools/CensorshipCheck.vue @@ -7,7 +7,8 @@
- diff --git a/frontend/components/advanced-tools/DnsResolver.vue b/frontend/components/advanced-tools/DnsResolver.vue index 9464ad7c9..232fcb50a 100644 --- a/frontend/components/advanced-tools/DnsResolver.vue +++ b/frontend/components/advanced-tools/DnsResolver.vue @@ -21,7 +21,9 @@
-