diff --git a/client_v2/AGENTS.md b/client_v2/AGENTS.md
index d1e2caa4eb1..49b4b57d7e6 100644
--- a/client_v2/AGENTS.md
+++ b/client_v2/AGENTS.md
@@ -324,6 +324,10 @@ must not depend on stores or components. Helpers are pure and dependency-free.
'./Foo.module.pcss'` — and reference classes as `s.button`, `s[variant]`.
Compose conditional classes with `clsx`, imported as `cn`:
`import cn from 'clsx'` (e.g. `cn(s.button, s.primary, { [s.active]: on() })`).
+- **Hover styles**: Wrap `:hover` styling in `@media (hover: hover)` so touch
+ devices do not keep a stuck highlight after a tap. Gate `:hover` together
+ with `:focus` on row links, since a tap also focuses an `` in Chrome;
+ leave `:focus-visible` outlines ungated for keyboard users.
- **Colors**: Never hardcode color hex values in `.pcss` or inline styles.
Always reference the CSS custom properties defined in
`src/common/styles/vars.css` and `src/common/styles/colors/*.css`
@@ -346,6 +350,14 @@ must not depend on stores or components. Helpers are pure and dependency-free.
a conditional class via `cn(...)`, not an inline `style`. Exception: a single
computed pixel offset for a dragging/positioning edge case may be acceptable
when no class-based solution exists — justify it in a comment.
+- **Comments**: Write comments only where they earn their place, and keep
+ them useful and compact — one or two lines explaining a non-obvious
+ *why*: a workaround, a browser quirk, or a cross-file constraint a
+ reader would otherwise break. Do not narrate what a rule or function
+ does, restate selector/property names, or spell out measurements,
+ arithmetic, and design history; that belongs with the value it
+ constrains or in the PR description. Applies to `.pcss`, `.tsx`, and
+ tests alike. Delete a comment once the code it describes changes.
- **Reactivity**: Use `createSignal` for local state, `createMemo` for
derived values, `createEffect` for side effects, `onMount` for initial data
fetches, and `onCleanup` for teardown. Do not read signals imperatively
diff --git a/client_v2/src/__locales/ar.json b/client_v2/src/__locales/ar.json
index 4f5e0a1503f..aa79b8deffd 100644
--- a/client_v2/src/__locales/ar.json
+++ b/client_v2/src/__locales/ar.json
@@ -460,11 +460,11 @@
"ip_address": "عنوان IP",
"known_tracker": "متعقب معروف",
"language": "اللغة",
- "last_days": "| آخر 0 يوم | آخر يوم واحد | آخر يومين | آخر %count% أيام | آخر %count% يومًا | آخر %count% يوم",
- "last_hours": "| آخر 0 ساعة | آخر ساعة واحدة | آخر ساعتين | آخر %count% ساعات | آخر %count% ساعة | آخر %count% ساعة",
+ "last_days": "| آخر يوم واحد | آخر يومين | آخر %count% أيام | آخر %count% يومًا | آخر %count% يوم",
+ "last_hours": "| آخر ساعة واحدة | آخر ساعتين | آخر %count% ساعات | آخر %count% ساعة | آخر %count% ساعة",
"last_updated_label": "آخر تحديث",
"light_theme": "المظهر فاتح",
- "list_updated": "| لا توجد قوائم محدثة | تم تحديث قائمة واحدة | تم تحديث قائمتين | تم تحديث %count% قوائم | تم تحديث %count% قائمة | تم تحديث %count% قائمة",
+ "list_updated": "| تم تحديث قائمة واحدة | تم تحديث قائمتين | تم تحديث %count% قوائم | تم تحديث %count% قائمة | تم تحديث %count% قائمة",
"login": "تسجيل الدخول",
"logout": "تسجيل خروج",
"make_static": "اجعلها ثابتة",
@@ -513,8 +513,8 @@
"password_requirements_uppercase": "على الأقل 1 حرف كبير",
"path_to_file_placeholder": "طريق الملف: عنوان محلي أو عنوان على الشبكة العنكبوتية",
"pause_for_hour": "إيقاف مؤقت لمدة %count% ساعة",
- "pause_for_minutes": "| إيقاف مؤقت لمدة 0 دقيقة | إيقاف مؤقت لمدة دقيقة واحدة | إيقاف مؤقت لمدة دقيقتين | إيقاف مؤقت لمدة %count% دقائق | إيقاف مؤقت لمدة %count% دقيقة | إيقاف مؤقت لمدة %count% دقيقة",
- "pause_for_seconds": "| إيقاف مؤقت لمدة 0 ثانية | إيقاف مؤقت لمدة ثانية واحدة | إيقاف مؤقت لمدة ثانيتين | إيقاف مؤقت لمدة %count% ثوانٍ | إيقاف مؤقت لمدة %count% ثانية | إيقاف مؤقت لمدة %count% ثانية",
+ "pause_for_minutes": "| إيقاف مؤقت لمدة دقيقة واحدة | إيقاف مؤقت لمدة دقيقتين | إيقاف مؤقت لمدة %count% دقائق | إيقاف مؤقت لمدة %count% دقيقة | إيقاف مؤقت لمدة %count% دقيقة",
+ "pause_for_seconds": "| إيقاف مؤقت لمدة ثانية واحدة | إيقاف مؤقت لمدة ثانيتين | إيقاف مؤقت لمدة %count% ثوانٍ | إيقاف مؤقت لمدة %count% ثانية | إيقاف مؤقت لمدة %count% ثانية",
"pause_until_tomorrow": "إيقاف مؤقت حتى الغد، %time%",
"period_notify": "لتغيير المدة الزمنية المعروضة، قم بتحرير الاحتفاظ بالاحصائيات في إعدادات الوضع العام",
"plain_dns": "عنوان DNS العادي",
@@ -635,14 +635,14 @@
"settings_confirm_decrease_stats_rotation_interval": "تقليل فترة الاحتفاظ بالإحصائيات؟",
"settings_confirm_decrease_stats_rotation_interval_desc": "سيؤدي هذا إلى حذف جميع الاحصائيات الأقدم من %value%",
"settings_custom": "مخصص",
- "settings_days": "| 0 يوم | يوم واحد | يومان | %count% أيام | %count% يومًا | %count% يوم",
+ "settings_days": "| يوم واحد | يومان | %count% أيام | %count% يومًا | %count% يوم",
"settings_domain_names": "أسماء النطاقات، واحد في كل سطر",
"settings_filter_requests": "تصفية الطلبات",
"settings_filter_requests_desc": "يمكنك استخدام Blocklists و Allowlists و قواعد المستخدم لإعداد قواعد التصفية",
"settings_filtering_and_security": "التصفية والأمان",
"settings_general_short": "الوضع العام",
"settings_global": "عالمي",
- "settings_hours": "| 0 ساعة | ساعة واحدة | ساعتان | %count% ساعات | %count% ساعة | %count% ساعة",
+ "settings_hours": "| ساعة واحدة | ساعتان | %count% ساعات | %count% ساعة | %count% ساعة",
"settings_log_rotation_hours": "تدوير السجل، بالساعات",
"settings_notify_changes_saved": "تم حفظ التغييرات",
"settings_notify_query_log_cleared": "تم مسح سجل الاستعلام",
diff --git a/client_v2/src/__locales/en.json b/client_v2/src/__locales/en.json
index bbaacddcb2f..de98913edd5 100644
--- a/client_v2/src/__locales/en.json
+++ b/client_v2/src/__locales/en.json
@@ -3,7 +3,6 @@
"add_allowlist": "Add allowlist",
"add_blocklist": "Add blocklist",
"add_persistent_client": "Add as persistent client",
- "add_to_allowlist": "Add to allowlist",
"ads_blocked": "Blocked ads",
"ads_blocked_card": "Blocked ads",
"ads_blocked_tooltip": "The number of DNS queries blocked by adblock filters and hosts blocklist",
@@ -12,7 +11,6 @@
"all_lists_up_to_date_toast": "All lists are already up-to-date",
"all_rewrites_disabled": "All custom DNS responses will be disabled",
"all_rewrites_enabled": "All custom DNS responses will be enabled",
- "allow_service": "Allow service",
"allowed": "Allowed",
"allowlist_add": "Add allowlist",
"allowlist_edit": "Edit allowlist",
@@ -137,7 +135,7 @@
"dashboard": "Dashboard",
"dashboard_statistics_disabled": "Statistics retention is disabled in General settings",
"delete_btn": "Delete",
- "delete_confirm": "Delete lease?",
+ "delete_confirm": "Remove lease?",
"delete_confirm_desc": "This will free %ip%",
"delete_table_action": "Delete",
"delete_table_action_confirm": "Yes, delete",
@@ -271,6 +269,7 @@
"dns_override_min_ttl_placeholder": "Time-to-live (TTL)",
"dns_override_min_ttl_title": "Override minimum TTL",
"dns_privacy": "DNS Privacy",
+ "dns_private_reverse_no_default_resolvers": "AdGuard Home could not determine suitable private reverse DNS resolvers for this system. Specify them to enable this setting.",
"dns_private_reverse_resolve_clients_desc": "Resolves client IP addresses to their hostnames using PTR queries. Uses private DNS servers for local clients and upstream servers for clients with public IP addresses",
"dns_private_reverse_resolve_clients_title": "Reverse lookup of client IP addresses",
"dns_private_reverse_resolvers": "Private reverse DNS resolvers",
@@ -280,9 +279,8 @@
"dns_private_reverse_servers_desc_2": "If this field is empty, your operating system's default DNS resolvers are used, except for AdGuard Home IP addresses. By default, AdGuard Home uses the following reverse DNS resolvers: %value_1% and %value_2%",
"dns_private_reverse_servers_label": "Private reverse DNS servers, one per line",
"dns_private_reverse_servers_placeholder": "IP addresses, sdns://, quic://, https://, h3://, or tls://",
+ "dns_private_reverse_servers_required": "Specify at least one private reverse DNS server or turn off private reverse DNS resolvers",
"dns_private_reverse_servers_resolvers": "Default DNS resolvers: %value_1% and %value_2%",
- "dns_private_reverse_servers_title": "Private reverse DNS servers",
- "dns_protocols_title": "DNS protocols",
"dns_queries": "DNS queries",
"dns_queries_tooltip": "The number of DNS queries processed for the last 24 hours",
"dns_query": "DNS queries",
@@ -299,7 +297,6 @@
"dns_rate_limit_title": "Rate limit",
"dns_rate_limit_value": "%value% req/s",
"dns_rewrite_exists": "This DNS rewrite already exists",
- "dns_rewrite_removed": "DNS rewrite removed",
"dns_rewrite_same": "You can’t rewrite to the same domain or wildcard",
"dns_rewrites": "DNS rewrites",
"dns_rewrites_desc": "Lists your custom DNS responses for specific domain names",
@@ -372,8 +369,6 @@
"encryption_server_tooltip": "The domain name of your server. It must match a DNS name in the certificate. If set, AdGuard Home will detect ClientIDs, respond to DDR queries, and perform additional connection validations",
"encryption_subject": "Subject: %value%",
"encryption_title": "Encryption",
- "encryption_unable_read_cert": "Unable to read certificate file",
- "encryption_unable_read_key": "Unable to read private key file",
"error": "Error",
"filter_added_successfully": "Added blocklist: %value%",
"filter_added_successfully_allowlist": "Added allowlist: %value%",
@@ -404,7 +399,6 @@
"form_error_answer_format": "Invalid result format",
"form_error_client_id_format": "ClientID must contain only numbers, lowercase letters, and hyphens",
"form_error_domain_format": "Invalid domain format",
- "form_error_equal": "Must not be equal",
"form_error_format": "Invalid format",
"form_error_format_line": "Invalid format on line %line%",
"form_error_format_lines": "Invalid format on lines %lines%",
@@ -447,15 +441,14 @@
"inactivity_schedule_edit": "Edit schedule",
"inactivity_schedule_no_schedules": "No schedule",
"inactivity_schedule_replace_desc": "This schedule will replace any existing schedules for the same day of the week. Each day can have only one inactivity period",
- "inactivity_schedule_time_from": "Time from",
- "inactivity_schedule_time_to": "Time to",
+ "inactivity_schedule_time_from": "Time from (HH:mm)",
+ "inactivity_schedule_time_to": "Time to (HH:mm)",
"inactivity_schedule_timezone": "Timezone",
"install_auth_confirm": "Confirm password",
"install_auth_password": "Password",
"install_auth_password_enter": "Enter password",
"install_auth_username": "Username",
"install_auth_username_enter": "Enter username",
- "install_saved": "Saved successfully",
"install_settings_all_interfaces": "All interfaces",
"install_settings_port": "Port",
"install_static_configure": "AdGuard Home has detected that the dynamic IP address %ip% is used. Do you want it to be set as your static address?",
@@ -519,7 +512,6 @@
"password_requirements_match": "Passwords match",
"password_requirements_special": "English letters, numbers, and special characters",
"password_requirements_uppercase": "At least 1 uppercase letter",
- "path_to_file_placeholder": "Path to file: local or Internet address",
"pause_for_hour": "Pause for %count% hour",
"pause_for_minutes": "| Pause for %count% minute | Pause for %count% minutes",
"pause_for_seconds": "| Pause for %count% second | Pause for %count% seconds",
@@ -582,7 +574,7 @@
"remove": "Remove",
"remove_tag": "Remove %value%",
"remove_tls_certificate": "Remove TLS certificate?",
- "remove_tls_certificate_desc": "Clients using encrypted DNS won’t be able to connect until a new TLS certificate is added",
+ "remove_tls_certificate_desc": "Removing the certificate will disable HTTPS and reset TLS settings. If you are connected over HTTPS, your session will end and you will be redirected to http://%host%:%port%",
"report_an_issue": "Report a problem",
"request_table_header": "Request",
"requests_table_header": "Requests",
@@ -621,6 +613,7 @@
"safe_search_used_tooltip": "The number of DNS queries to search engines for which Safe Search was enforced",
"saturday": "Saturday",
"save": "Save",
+ "save_anyway": "Save anyway",
"save_btn": "Save",
"search_placeholder": "Search",
"select_language": "Select language",
@@ -658,7 +651,7 @@
"settings_notify_query_log_cleared": "Query log cleared",
"settings_notify_statistics_cleared": "Statistics cleared",
"settings_parental_control": "Parental control",
- "settings_parental_control_desc": "Block websites with content inappropriate for children",
+ "settings_parental_control_desc": "Block websites with inappropriate content, such as pornography, violence, gore, and other disturbing material, using AdGuard’s continuously updated family filter database",
"settings_rotation_placeholder": "Use numbers",
"settings_rule_syntax": "Rule syntax",
"settings_safe_search": "Safe search",
@@ -793,7 +786,7 @@
"tls_setup_config_title": "Enable encrypted DNS",
"tls_setup_enable_anyway": "Enable anyway",
"tls_setup_error_cert_incomplete": "Certificate is incomplete. Paste the full text, including the BEGIN and END lines",
- "tls_setup_error_duplicate_port": "Port %port% is used by multiple DNS protocols. It must be unique",
+ "tls_setup_error_duplicate_port": "Port %port% is used by more than one protocol. Choose a different port for each encrypted DNS protocol",
"tls_setup_error_ed25519_key": "This key type is not supported by browsers. Use an RSA or ECDSA key instead",
"tls_setup_error_enable_failed": "Unable to enable encrypted DNS. Check the settings and try again",
"tls_setup_error_key_incomplete": "Private key is incomplete. Paste the full text, including the BEGIN and END lines",
diff --git a/client_v2/src/__locales/hr.json b/client_v2/src/__locales/hr.json
index 2f61c282675..6cba256b700 100644
--- a/client_v2/src/__locales/hr.json
+++ b/client_v2/src/__locales/hr.json
@@ -460,11 +460,11 @@
"ip_address": "IP adresa",
"known_tracker": "Poznati pratitelj",
"language": "Jezik",
- "last_days": "| Zadnji %count% dan | Zadnjih %count% dana",
- "last_hours": "| Zadnjih %count% sat | Zadnjih %count% sati",
+ "last_days": "| Zadnji %count% dan | Zadnja %count% dana | Zadnjih %count% dana",
+ "last_hours": "| Zadnjih %count% sat | Zadnja %count% sata | Zadnjih %count% sati",
"last_updated_label": "Zadnja izmjena",
"light_theme": "Svijetla tema",
- "list_updated": "| %count% popis ažuriran | %count% popisi ažurirani",
+ "list_updated": "| %count% popis ažuriran | %count% popisa ažurirana | %count% popisi ažurirani",
"login": "PRIJAVA",
"logout": "Odjava",
"make_static": "Učini statičnim",
@@ -513,8 +513,8 @@
"password_requirements_uppercase": "Najmanje jedno veliko slovo",
"path_to_file_placeholder": "Put do datoteke: lokalna ili internetska adresa",
"pause_for_hour": "Pauza na %count% sat",
- "pause_for_minutes": "| Pauza od %count% minute | Pauza od %count% minuta",
- "pause_for_seconds": "| Pauza %count% sekunde | Pauza na %count% sekunde",
+ "pause_for_minutes": "| Pauza od %count% minute | Pauza od %count% minute | Pauza od %count% minuta",
+ "pause_for_seconds": "| Pauza %count% sekunde | Pauza na %count% sekunde | Pauza na %count% sekunde",
"pause_until_tomorrow": "Pauza do sutra, %time%",
"period_notify": "Za promjenu prikazanih razdoblja uredite Zadržavanje statistike u Općim postavkama",
"plain_dns": "Obični DNS",
@@ -635,14 +635,14 @@
"settings_confirm_decrease_stats_rotation_interval": "Smanjiti interval zadržavanja statistika?",
"settings_confirm_decrease_stats_rotation_interval_desc": "To će izbrisati sve statistike starije od %value%",
"settings_custom": "Prilagođeno",
- "settings_days": "| %count% dan | %count% dana",
+ "settings_days": "| %count% dan | %count% dana | %count% dana",
"settings_domain_names": "Domene, po jedna po liniji",
"settings_filter_requests": "Zahtjevi za filtriranje",
"settings_filter_requests_desc": "Možete koristiti Blocklists, Allowlists i Korisnička pravila za postavljanje pravila filtriranja",
"settings_filtering_and_security": "Filtriranje i sigurnost",
"settings_general_short": "Općenito",
"settings_global": "Globalno",
- "settings_hours": "| %count% sat | %count% sati",
+ "settings_hours": "| %count% sat | %count% sata | %count% sati",
"settings_log_rotation_hours": "Rotacija trupaca, u satima",
"settings_notify_changes_saved": "Promjene su spremljene",
"settings_notify_query_log_cleared": "Dnevnik upita očišćen",
diff --git a/client_v2/src/__tests__/common/controls/Textarea.test.tsx b/client_v2/src/__tests__/common/controls/Textarea.test.tsx
index 1aaa17ad70f..461001beb79 100644
--- a/client_v2/src/__tests__/common/controls/Textarea.test.tsx
+++ b/client_v2/src/__tests__/common/controls/Textarea.test.tsx
@@ -115,3 +115,35 @@ describe('Textarea — comment highlight', () => {
expect(overlay?.parentElement).toBe(textarea?.parentElement);
});
});
+
+describe('Textarea — onCard', () => {
+ it('applies the onCard class to the field when set', () => {
+ const { container } = render(() => );
+
+ expect(container.querySelector('textarea')?.className).toContain('onCard');
+ });
+
+ it('does not apply the onCard class by default', () => {
+ const { container } = render(() => );
+
+ expect(container.querySelector('textarea')?.className).not.toContain('onCard');
+ });
+
+ it('applies the onCard class to the scroll container in highlighting mode', () => {
+ const { container } = render(() => (
+
+ ));
+
+ // The visible surface in highlighting mode is the scroll container,
+ // not the transparent textarea layered on top of it.
+ const textarea = container.querySelector('textarea');
+ const scrollArea = textarea?.closest('[class*="scrollArea"]');
+ expect(scrollArea?.className).toContain('onCard');
+ expect(textarea?.className).not.toContain('onCard');
+ });
+});
diff --git a/client_v2/src/__tests__/common/ui/Banners.test.tsx b/client_v2/src/__tests__/common/ui/Banners.test.tsx
index aee11b29d82..477f36c1bf7 100644
--- a/client_v2/src/__tests__/common/ui/Banners.test.tsx
+++ b/client_v2/src/__tests__/common/ui/Banners.test.tsx
@@ -69,6 +69,9 @@ const resetStores = () => {
describe('Banners', () => {
beforeEach(() => {
resetStores();
+ // The Update buttons navigate; start every test from the root route so
+ // a previous navigation cannot hide the banner.
+ window.location.hash = '';
});
// ── Priority logic cases ──
@@ -210,6 +213,32 @@ describe('Banners', () => {
expect(screen.queryByTestId('banner-tls-expired')).not.toBeInTheDocument();
});
+ // ── Update button deep-links into the TLS setup wizard ──
+
+ it('opens the TLS setup wizard from the expired banner', async () => {
+ const user = userEvent.setup();
+ mockEncryptionState.enabled = true;
+ mockEncryptionState.valid_cert = true;
+ mockEncryptionState.not_after = new Date(Date.now() - 86400000).toISOString(); // expired
+
+ renderBanners();
+ await user.click(screen.getByRole('button', { name: copyInDom('update_button') }));
+
+ expect(window.location.hash).toBe('#/encryption?tlsWizard=true');
+ });
+
+ it('opens the TLS setup wizard from the expiring banner', async () => {
+ const user = userEvent.setup();
+ mockEncryptionState.enabled = true;
+ mockEncryptionState.valid_cert = true;
+ mockEncryptionState.not_after = new Date(Date.now() + 15 * 86400000).toISOString(); // 15 days
+
+ renderBanners();
+ await user.click(screen.getByRole('button', { name: copyInDom('update_button') }));
+
+ expect(window.location.hash).toBe('#/encryption?tlsWizard=true');
+ });
+
// ── forceBanner (dev test override) ──
it('renders forced TLS expired banner regardless of store state', () => {
diff --git a/client_v2/src/__tests__/common/ui/ClientBlockMenuItem.test.tsx b/client_v2/src/__tests__/common/ui/ClientBlockMenuItem.test.tsx
new file mode 100644
index 00000000000..390c2da3613
--- /dev/null
+++ b/client_v2/src/__tests__/common/ui/ClientBlockMenuItem.test.tsx
@@ -0,0 +1,44 @@
+import { render, screen } from '@solidjs/testing-library';
+import { describe, it, expect, vi } from 'vitest';
+import userEvent from '@testing-library/user-event';
+
+import { ClientBlockMenuItem } from 'panel/common/ui/ClientBlockConfirm';
+import { copyInDom } from 'panel/__tests__/helpers/copy';
+
+describe('ClientBlockMenuItem', () => {
+ it('renders the block action as a button', async () => {
+ const onClick = vi.fn();
+ render(() => );
+
+ const button = screen.getByRole('button', { name: copyInDom('block_client') });
+ expect(button.tagName).toBe('BUTTON');
+ expect(button.getAttribute('type')).toBe('button');
+
+ await userEvent.click(button);
+ expect(onClick).toHaveBeenCalledWith('block');
+ });
+
+ it('renders the unblock action as a button', async () => {
+ const onClick = vi.fn();
+ render(() => );
+
+ const button = screen.getByRole('button', { name: copyInDom('unblock_client') });
+ expect(button.tagName).toBe('BUTTON');
+ expect(button.getAttribute('type')).toBe('button');
+
+ await userEvent.click(button);
+ expect(onClick).toHaveBeenCalledWith('unblock');
+ });
+
+ it('exposes the action through a test id', () => {
+ render(() => {}} />);
+ expect(screen.getByTestId('client-block-menu-item')).toBeInTheDocument();
+ });
+
+ it('merges the caller class onto the button', () => {
+ render(() => (
+ {}} class="custom-class" />
+ ));
+ expect(screen.getByRole('button').className).toContain('custom-class');
+ });
+});
diff --git a/client_v2/src/__tests__/common/ui/ConfigDialog.test.tsx b/client_v2/src/__tests__/common/ui/ConfigDialog.test.tsx
index d616049d2e0..9fa6ec06df1 100644
--- a/client_v2/src/__tests__/common/ui/ConfigDialog.test.tsx
+++ b/client_v2/src/__tests__/common/ui/ConfigDialog.test.tsx
@@ -116,4 +116,31 @@ describe('ConfigDialog', () => {
const fieldset = screen.getByTestId('test-input').closest('fieldset');
expect(fieldset).toBeDisabled();
});
+
+ it('keeps the primary submit style by default', () => {
+ render(() => (
+
+
Content
+
+ ));
+ expect(screen.getByTestId('config-dialog-save').className).toContain('primary');
+ });
+
+ it('styles the submit button as a warning when asked', () => {
+ render(() => (
+
+
Content
+
+ ));
+ const saveButton = screen.getByTestId('config-dialog-save');
+ expect(saveButton.className).toContain('warning');
+ expect(saveButton).toHaveTextContent('save_anyway');
+ });
});
diff --git a/client_v2/src/__tests__/common/ui/link.test.tsx b/client_v2/src/__tests__/common/ui/link.test.tsx
new file mode 100644
index 00000000000..4837bde5e9d
--- /dev/null
+++ b/client_v2/src/__tests__/common/ui/link.test.tsx
@@ -0,0 +1,75 @@
+import { render, screen, fireEvent } from '@solidjs/testing-library';
+import { MemoryRouter, Route, createMemoryHistory } from '@solidjs/router';
+import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
+
+import { Link } from 'panel/common/ui/Link';
+import { RoutePath } from 'panel/components/Routes/Paths';
+
+import type { QueryParams } from 'panel/components/Routes/Paths';
+
+/**
+ * A memory router keeps each case on its own history: following a link in one
+ * test would otherwise leave the next one on a route it does not declare.
+ */
+const renderRow = (children?: unknown, query?: QueryParams) => {
+ const history = createMemoryHistory();
+ history.set({ value: '/' });
+
+ return render(() => (
+
+ (
+
+ {(children ?? 'row content') as never}
+
+ )}
+ />
+
+ ));
+};
+
+describe('Link scroll-to-top', () => {
+ let scrollTo: ReturnType;
+
+ beforeEach(() => {
+ vi.useFakeTimers();
+ scrollTo = vi.spyOn(window, 'scrollTo').mockImplementation(() => {});
+ });
+
+ afterEach(() => {
+ scrollTo.mockRestore();
+ vi.useRealTimers();
+ });
+
+ it('scrolls to the top after following the link', () => {
+ renderRow();
+ fireEvent.click(screen.getByTestId('row'));
+
+ vi.advanceTimersByTime(150);
+
+ expect(scrollTo).toHaveBeenCalledWith({ top: 0 });
+ });
+
+ it('does not scroll when a nested control cancels the navigation', () => {
+ renderRow(
+ ,
+ );
+ fireEvent.click(screen.getByRole('button'));
+
+ vi.advanceTimersByTime(150);
+
+ expect(scrollTo).not.toHaveBeenCalled();
+ });
+
+ it('does not scroll when the target section handles it', () => {
+ renderRow(undefined, { section: 'settings' });
+ fireEvent.click(screen.getByTestId('row'));
+
+ vi.advanceTimersByTime(150);
+
+ expect(scrollTo).not.toHaveBeenCalled();
+ });
+});
diff --git a/client_v2/src/__tests__/common/ui/truncated-text.test.tsx b/client_v2/src/__tests__/common/ui/truncated-text.test.tsx
new file mode 100644
index 00000000000..326c8a4a27b
--- /dev/null
+++ b/client_v2/src/__tests__/common/ui/truncated-text.test.tsx
@@ -0,0 +1,53 @@
+import { render, screen } from '@solidjs/testing-library';
+import { describe, it, expect, beforeEach, vi } from 'vitest';
+
+import { TruncatedText } from 'panel/common/ui/TruncatedText';
+import { mockMatchMedia } from 'panel/__tests__/helpers/matchMedia';
+
+/** jsdom reports every element as 0×0, so the measurement is stubbed. */
+const truncated = vi.hoisted(() => ({ value: false }));
+
+vi.mock('panel/hooks/useIsTruncated', () => ({
+ useIsTruncated: () => () => truncated.value,
+}));
+
+const LONG_TEXT = 'very-long-subdomain.tracking-vendor.example.com';
+
+describe('TruncatedText', () => {
+ beforeEach(() => {
+ truncated.value = false;
+ mockMatchMedia(false);
+ });
+
+ it('renders the text without a native title', () => {
+ const { container } = render(() => );
+
+ expect(screen.getByTestId('cell')).toHaveTextContent(LONG_TEXT);
+ expect(container.querySelector('[title]')).toBeNull();
+ });
+
+ it('renders no tooltip while the text fits', () => {
+ const { container } = render(() => );
+
+ expect(container.querySelector('[data-scope="tooltip"]')).toBeNull();
+ });
+
+ it('reveals the full text in a tooltip once it is clipped', () => {
+ truncated.value = true;
+
+ const { container } = render(() => );
+
+ expect(container.querySelector('[data-part="content"]')).toHaveTextContent(LONG_TEXT);
+ expect(container.querySelector('[title]')).toBeNull();
+ });
+
+ it('renders no tooltip on touch devices', () => {
+ truncated.value = true;
+ mockMatchMedia((query) => query === '(hover: none)');
+
+ const { container } = render(() => );
+
+ expect(screen.getByText(LONG_TEXT)).toBeInTheDocument();
+ expect(container.querySelector('[data-scope="tooltip"]')).toBeNull();
+ });
+});
diff --git a/client_v2/src/__tests__/components/Clients/service-icons.test.tsx b/client_v2/src/__tests__/components/Clients/service-icons.test.tsx
new file mode 100644
index 00000000000..c7b9cd9f541
--- /dev/null
+++ b/client_v2/src/__tests__/components/Clients/service-icons.test.tsx
@@ -0,0 +1,107 @@
+import { describe, it, expect } from 'vitest';
+import { render, screen } from '@solidjs/testing-library';
+
+import {
+ ServiceIcons,
+ type WebService,
+} from 'panel/components/Clients/blocks/PersistentClientsTable/ServiceIcons';
+
+const SVG = '';
+
+const makeServiceMap = (ids: string[]) =>
+ new Map(
+ ids.map((id) => [
+ id,
+ { id, name: id, icon_svg: SVG, group_id: 'other', rules: [] as string[] },
+ ]),
+ );
+
+const renderStrip = (serviceIds: string[], maxVisible?: number) =>
+ render(() => (
+
+ ));
+
+const renderedIcons = () => screen.queryAllByTestId('service-icon');
+
+const badgeCount = () => screen.queryByTestId('services-count')?.textContent ?? null;
+
+const manyServices = (count: number) => Array.from({ length: count }, (_, i) => `service-${i}`);
+
+describe('ServiceIcons', () => {
+ it('renders two inline icons and counts the remaining ones', () => {
+ renderStrip(manyServices(12));
+
+ expect(renderedIcons()).toHaveLength(2);
+ expect(badgeCount()).toBe('10');
+ });
+
+ it('keeps the icon count and the badge in sync with the total', () => {
+ // The invariant that broke the layout: the strip must never render more
+ // icons than it accounts for, otherwise the extra icon overflows the cell.
+ renderStrip(manyServices(12));
+
+ const visible = renderedIcons().length;
+ expect(visible + Number(badgeCount())).toBe(12);
+ });
+
+ it('collapses the third service into the count chip', () => {
+ // Regression guard: three inline icons need 102px but the narrowest
+ // `blocked_services` cell only provides 96px, so the last icon used to
+ // be clipped mid-glyph.
+ renderStrip(manyServices(3));
+
+ expect(renderedIcons()).toHaveLength(2);
+ expect(badgeCount()).toBe('1');
+ });
+
+ it('renders no count chip when every service fits', () => {
+ renderStrip(manyServices(2));
+
+ expect(renderedIcons()).toHaveLength(2);
+ expect(badgeCount()).toBeNull();
+ });
+
+ it('renders a single icon without a count chip', () => {
+ renderStrip(manyServices(1));
+
+ expect(renderedIcons()).toHaveLength(1);
+ expect(badgeCount()).toBeNull();
+ });
+
+ it('renders no icons when there are no blocked services', () => {
+ renderStrip([]);
+
+ expect(renderedIcons()).toHaveLength(0);
+ expect(badgeCount()).toBeNull();
+ });
+
+ it('does not render icons for ids missing from the service map', () => {
+ render(() => (
+
+ ));
+
+ expect(renderedIcons()).toHaveLength(1);
+ expect(badgeCount()).toBeNull();
+ });
+
+ it('respects an explicit maxVisible override', () => {
+ renderStrip(manyServices(12), 1);
+
+ expect(renderedIcons()).toHaveLength(1);
+ expect(badgeCount()).toBe('11');
+ });
+
+ it('does not truncate the icons when the override exceeds the default', () => {
+ renderStrip(manyServices(12), 5);
+
+ expect(renderedIcons()).toHaveLength(5);
+ expect(badgeCount()).toBe('7');
+ });
+});
diff --git a/client_v2/src/__tests__/components/Dashboard/stats-period.test.tsx b/client_v2/src/__tests__/components/Dashboard/stats-period.test.tsx
new file mode 100644
index 00000000000..8d317a6fdf8
--- /dev/null
+++ b/client_v2/src/__tests__/components/Dashboard/stats-period.test.tsx
@@ -0,0 +1,254 @@
+import { For } from 'solid-js';
+import { render, screen, fireEvent } from '@solidjs/testing-library';
+import { describe, it, expect, vi, beforeEach } from 'vitest';
+
+import { DAY } from 'panel/helpers/constants';
+import { LocalStorageHelper, LOCAL_STORAGE_KEYS } from 'panel/helpers/localStorageHelper';
+
+type StatsStateMock = {
+ processingGetConfig: boolean;
+ processingStats: boolean;
+ configLoaded: boolean;
+ configAttempted: boolean;
+ statsAttempted: boolean;
+ interval: number;
+ enabled: boolean;
+ numDnsQueries: number;
+ dnsQueries: number[];
+ topClients: { name: string; count: number }[];
+ topQueriedDomains: { name: string; count: number }[];
+};
+
+type HeaderPropsMock = {
+ selectedPeriod: number;
+ periodOptions: { value: number; label: string }[];
+ onPeriodChange: (period: number) => void;
+};
+
+const mocks = vi.hoisted(() => ({
+ getStats: vi.fn(),
+ getStatsConfig: vi.fn(),
+ statsState: undefined as StatsStateMock | undefined,
+}));
+
+vi.mock('panel/stores/stats', async () => {
+ const { createMutable } = await import('solid-js/store');
+
+ // A mutable store (not a plain object) so the Dashboard memos react when
+ // the config "arrives", exactly like the real store does.
+ const statsState = createMutable({
+ processingGetConfig: false,
+ processingStats: true,
+ configLoaded: false,
+ configAttempted: false,
+ statsAttempted: false,
+ interval: DAY,
+ enabled: true,
+ numDnsQueries: 0,
+ dnsQueries: [],
+ topClients: [],
+ topQueriedDomains: [],
+ });
+ mocks.statsState = statsState;
+
+ return {
+ statsState,
+ getStats: mocks.getStats,
+ getStatsConfig: mocks.getStatsConfig,
+ enableStatistics: vi.fn(),
+ };
+});
+
+vi.mock('panel/stores/dashboard', () => ({
+ dashboardState: {
+ protectionEnabled: true,
+ processingProtection: false,
+ protectionDisabledDuration: null,
+ },
+ toggleProtection: vi.fn(),
+ getClients: vi.fn(),
+}));
+
+vi.mock('panel/stores/access', () => ({
+ accessState: { processing: false },
+ getAccessList: vi.fn(),
+}));
+
+// The Header is what puts the period into the dropdown, so the stub renders the
+// props the Dashboard passes instead of the real Select.
+vi.mock('panel/components/Dashboard/blocks/Header/Header', () => ({
+ Header: (props: HeaderPropsMock) => (
+