diff --git a/.codex/skills/design-vpn-client-ui/SKILL.md b/.codex/skills/design-vpn-client-ui/SKILL.md
index 441bc79..a6c792d 100644
--- a/.codex/skills/design-vpn-client-ui/SKILL.md
+++ b/.codex/skills/design-vpn-client-ui/SKILL.md
@@ -28,6 +28,8 @@ Preserve the repo's focused one-screen VPN client language: a centered primary a
- Let visible cycles finish. Never stop a spinner mid-turn or remount a list before its exit animation completes.
- Prefer one clear value over unsupported detail. Hide subscription fields the provider does not supply.
- Keep client UI compact and calm. Do not introduce dashboard cards, decorative chrome, or admin-console density.
+- Do not use a modal, popup, or blocking backdrop unless the user explicitly asks for one. Prefer inline disclosure or a non-modal layer that preserves the main screen.
+- Avoid borders, divider lines, and framed regions by default. Build hierarchy with spacing, typography, subtle surface changes, light, and depth; use a line only when it communicates an essential state.
## Acceptance pass
diff --git a/.codex/skills/design-vpn-client-ui/references/visual-language.md b/.codex/skills/design-vpn-client-ui/references/visual-language.md
index f8c3c99..4861a5a 100644
--- a/.codex/skills/design-vpn-client-ui/references/visual-language.md
+++ b/.codex/skills/design-vpn-client-ui/references/visual-language.md
@@ -11,7 +11,8 @@ Design for a macOS user glancing at a small VPN control surface in a quiet deskt
- Build the left flow vertically: power icon, stable connection copy, proxy address, copy actions.
- Place subscription identity, usage, expiry, and servers in a compact column to the right.
- Collapse to one centered column on narrow screens.
-- Avoid cards and enclosing frames. Use spacing, type, thin rules, and state color for hierarchy.
+- Avoid enclosing frames, borders, and divider lines. Use spacing, type, subtle surface changes, light, depth, and state color for hierarchy.
+- Do not introduce popups or modals without an explicit user request. Prefer inline disclosure or a non-modal side layer when supporting content must coexist with the main control surface.
- Keep server rows vertical and narrow. Underlines should be only slightly wider than the server label and ping.
## Geometry and alignment
diff --git a/src/server/index.js b/src/server/index.js
index deb7cf3..0893797 100644
--- a/src/server/index.js
+++ b/src/server/index.js
@@ -11,14 +11,17 @@ import {
removeSingboxConfig,
writeSingboxConfig,
} from './singbox.js';
-import { fetchSubscription, fetchSubscriptionInfo } from './subscription.js';
+import { fetchSubscription, selectRefreshedServer } from './subscription.js';
const MAX_BODY_BYTES = 1_000_000;
+const SUBSCRIPTION_REFRESH_INTERVAL_MS = 15 * 60 * 1000;
fs.mkdirSync(settings.dataDir, { recursive: true });
let singboxProcess = null;
let singboxStartedAt = null;
+let subscriptionRefreshPromise = null;
+let subscriptionRefreshTimer = null;
function readJson(filePath, fallback) {
try {
@@ -195,6 +198,69 @@ async function applySelectedServer(selectedTag) {
});
}
+function refreshSavedSubscription() {
+ if (subscriptionRefreshPromise) return subscriptionRefreshPromise;
+
+ subscriptionRefreshPromise = (async () => {
+ const initialState = readJson(settings.statePath, {});
+ if (!initialState.subscriptionUrl) {
+ const error = new Error('Подписка не настроена');
+ error.statusCode = 400;
+ throw error;
+ }
+
+ const subscriptionUrl = initialState.subscriptionUrl;
+ const parsed = await fetchSubscription(subscriptionUrl);
+ const currentState = readJson(settings.statePath, {});
+ if (currentState.subscriptionUrl !== subscriptionUrl) {
+ throw new Error('Подписка была изменена во время обновления');
+ }
+
+ const selectedTag = selectRefreshedServer(currentState.selectedTag, parsed.servers);
+ const previousCache = readJson(settings.subscriptionCachePath, null);
+ const previousConfig = fs.existsSync(settings.configPath)
+ ? fs.readFileSync(settings.configPath, 'utf8')
+ : null;
+ writeJson(settings.subscriptionCachePath, { url: subscriptionUrl, ...parsed });
+
+ try {
+ if (singboxProcess && selectedTag) await applySelectedServer(selectedTag);
+ else if (selectedTag) {
+ writeSingboxConfig(buildGatewayConfig(parsed.config, selectedTag));
+ } else {
+ removeSingboxConfig();
+ }
+ } catch (error) {
+ if (previousCache) writeJson(settings.subscriptionCachePath, previousCache);
+ else fs.rmSync(settings.subscriptionCachePath, { force: true });
+ if (previousConfig === null) removeSingboxConfig();
+ else fs.writeFileSync(settings.configPath, previousConfig, 'utf8');
+ throw error;
+ }
+
+ writeJson(settings.statePath, {
+ ...readJson(settings.statePath, {}),
+ subscriptionUrl,
+ servers: parsed.servers,
+ userInfo: parsed.userInfo,
+ fetchedAt: parsed.fetchedAt,
+ selectedTag,
+ });
+
+ return {
+ success: true,
+ servers: parsed.servers,
+ userInfo: parsed.userInfo,
+ fetchedAt: parsed.fetchedAt,
+ selectedTag,
+ };
+ })().finally(() => {
+ subscriptionRefreshPromise = null;
+ });
+
+ return subscriptionRefreshPromise;
+}
+
async function handleApi(req, res) {
if (req.method === 'GET' && req.url === '/api/state') {
return sendJson(res, 200, publicState());
@@ -236,16 +302,8 @@ async function handleApi(req, res) {
return sendJson(res, 200, { success: true, ...parsed });
}
- if (req.method === 'POST' && req.url === '/api/subscription/refresh-info') {
- const state = readJson(settings.statePath, {});
- if (!state.subscriptionUrl) {
- return sendJson(res, 400, { success: false, error: 'Подписка не настроена' });
- }
- const info = await fetchSubscriptionInfo(state.subscriptionUrl);
- writeJson(settings.statePath, { ...state, ...info });
- const cached = readJson(settings.subscriptionCachePath, null);
- if (cached) writeJson(settings.subscriptionCachePath, { ...cached, ...info });
- return sendJson(res, 200, { success: true, ...info });
+ if (req.method === 'POST' && req.url === '/api/subscription/refresh') {
+ return sendJson(res, 200, await refreshSavedSubscription());
}
if (req.method === 'DELETE' && req.url === '/api/subscription') {
@@ -319,6 +377,7 @@ const server = http.createServer(async (req, res) => {
});
async function shutdown() {
+ clearInterval(subscriptionRefreshTimer);
await stopSingbox();
process.exit(0);
}
@@ -336,3 +395,10 @@ await startSingbox().catch((error) => console.warn(`[control] sing-box не за
server.listen(settings.port, '0.0.0.0', () => {
console.log(`[control] ${settings.appMode} UI слушает :${settings.port}`);
});
+
+subscriptionRefreshTimer = setInterval(() => {
+ if (!readJson(settings.statePath, {}).subscriptionUrl) return;
+ refreshSavedSubscription()
+ .catch((error) => console.warn(`[control] подписка не обновлена: ${error.message}`));
+}, SUBSCRIPTION_REFRESH_INTERVAL_MS);
+subscriptionRefreshTimer.unref();
diff --git a/src/server/subscription.js b/src/server/subscription.js
index a3a7529..33be146 100644
--- a/src/server/subscription.js
+++ b/src/server/subscription.js
@@ -160,13 +160,13 @@ async function requestSubscription(url) {
return response;
}
-export async function fetchSubscriptionInfo(url) {
- const response = await requestSubscription(url);
- await response.body?.cancel();
- return {
- userInfo: parseUserInfo(response.headers.get('subscription-userinfo')),
- fetchedAt: new Date().toISOString(),
- };
+export function selectRefreshedServer(currentTag, servers) {
+ const selectedTag = String(currentTag || '').trim();
+ if (!selectedTag) return '';
+
+ return servers.find((server) => String(server.tag || '').trim() === selectedTag)?.tag
+ || servers[0]?.tag
+ || '';
}
export async function fetchSubscription(url) {
diff --git a/src/web/App.jsx b/src/web/App.jsx
index 242598a..df0f05a 100644
--- a/src/web/App.jsx
+++ b/src/web/App.jsx
@@ -16,7 +16,11 @@ function App() {
const data = await api.state();
setState(data);
setServers(data.servers || []);
- setPendingTag((current) => current || data.selectedTag || '');
+ setPendingTag((current) => (
+ (data.servers || []).some((server) => server.tag === current)
+ ? current
+ : data.selectedTag || ''
+ ));
}
useEffect(() => {
@@ -51,13 +55,17 @@ function App() {
});
}
- async function refreshSubscriptionInfo() {
- const data = await api.subscription.refreshInfo();
+ async function refreshSubscription() {
+ const data = await api.subscription.refresh();
setState((current) => current ? {
...current,
userInfo: data.userInfo,
fetchedAt: data.fetchedAt,
+ servers: data.servers,
+ selectedTag: data.selectedTag,
} : current);
+ setServers(data.servers || []);
+ setPendingTag(data.selectedTag || '');
return data;
}
@@ -87,7 +95,7 @@ function App() {
pendingTag={pendingTag}
setPendingTag={setPendingTag}
onFetchSubscription={fetchSubscription}
- onRefreshSubscriptionInfo={refreshSubscriptionInfo}
+ onRefreshSubscription={refreshSubscription}
onForgetSubscription={forgetSubscription}
onApply={(tag) => run(() => api.apply(tag))}
onRestart={() => run(api.singbox.restart)}
diff --git a/src/web/api.js b/src/web/api.js
index bc6071e..460dca8 100644
--- a/src/web/api.js
+++ b/src/web/api.js
@@ -20,7 +20,7 @@ export const api = {
method: 'POST',
body: JSON.stringify({ url }),
}),
- refreshInfo: () => request('/api/subscription/refresh-info', { method: 'POST' }),
+ refresh: () => request('/api/subscription/refresh', { method: 'POST' }),
forget: () => request('/api/subscription', { method: 'DELETE' }),
},
apply: (selectedTag) => request('/api/apply', {
diff --git a/src/web/components/ClientOverviewPage.jsx b/src/web/components/ClientOverviewPage.jsx
index 8d3d64d..5eafff8 100644
--- a/src/web/components/ClientOverviewPage.jsx
+++ b/src/web/components/ClientOverviewPage.jsx
@@ -1,4 +1,5 @@
import React, { useEffect, useRef, useState } from 'react';
+import { flushSync } from 'react-dom';
import { api } from '../api.js';
import {
connectionAction,
@@ -10,6 +11,55 @@ import {
subscriptionUsage,
} from '../utils/clientControls.js';
import { formatBytes } from '../utils/format.js';
+import { instructionBlocks } from '../instructions.js';
+
+function InstructionStep({ step }) {
+ if (typeof step === 'string') return step;
+ return (
+ <>
+ {step.before}
+ {step.link[0]}
+ {step.after}
+ >
+ );
+}
+
+function InstructionBlock({ block, open, onToggle }) {
+ return (
+
+
+
+
+ {block.paragraphs?.map((paragraph) =>
{paragraph}
)}
+ {block.steps && (
+
+ {block.steps.map((step) => (
+ -
+
+
+ ))}
+
+ )}
+ {block.code &&
{block.code}}
+ {block.note &&
{block.note}
}
+
+
+
+ );
+}
export function ClientOverviewPage({
state,
@@ -21,7 +71,7 @@ export function ClientOverviewPage({
pendingTag,
setPendingTag,
onFetchSubscription,
- onRefreshSubscriptionInfo,
+ onRefreshSubscription,
onForgetSubscription,
onApply,
onRestart,
@@ -42,6 +92,8 @@ export function ClientOverviewPage({
const [usageUpdated, setUsageUpdated] = useState(false);
const [serverRevealVersion, setServerRevealVersion] = useState(0);
const [serversLeaving, setServersLeaving] = useState(false);
+ const [instructionsOpen, setInstructionsOpen] = useState(false);
+ const [openInstructionId, setOpenInstructionId] = useState('');
const subscriptionInputRef = useRef(null);
const subscriptionRef = useRef(null);
const copyTimerRef = useRef(null);
@@ -53,6 +105,16 @@ export function ClientOverviewPage({
const hasUsage = Boolean(
state?.userInfo && ['upload', 'download', 'total', 'expire'].some((key) => key in state.userInfo),
);
+ const instructions = instructionBlocks({
+ isGateway,
+ host: gatewayAddress,
+ port: state?.proxyPort || (isGateway ? 8080 : 8082),
+ });
+ const [instructionsIntro, ...instructionGuides] = instructions;
+ const openInstruction = instructionGuides.find((block) => block.id === openInstructionId);
+ const orderedInstructionGuides = openInstruction
+ ? [openInstruction, ...instructionGuides.filter((block) => block.id !== openInstructionId)]
+ : instructionGuides;
useEffect(() => {
setNow(Date.now());
@@ -92,7 +154,10 @@ export function ClientOverviewPage({
}, [editingSubscription]);
useEffect(() => {
- if (!hasSubscription) setEditingSubscription(true);
+ if (!hasSubscription) {
+ setEditingSubscription(true);
+ setInstructionsOpen(false);
+ }
}, [hasSubscription]);
useEffect(() => {
@@ -114,10 +179,8 @@ export function ClientOverviewPage({
useEffect(() => {
if (!state?.hasSubscription) return undefined;
- const refresh = () => onRefreshSubscriptionInfo().catch(() => {});
- refresh();
- const timer = setInterval(refresh, 60_000);
- return () => clearInterval(timer);
+ onRefreshSubscription().catch(() => {});
+ return undefined;
}, [state?.hasSubscription]);
useEffect(() => {
@@ -138,6 +201,15 @@ export function ClientOverviewPage({
useEffect(() => () => clearTimeout(copyTimerRef.current), []);
+ useEffect(() => {
+ if (!instructionsOpen) return undefined;
+ const closeOnEscape = (event) => {
+ if (event.key === 'Escape') setInstructionsOpen(false);
+ };
+ document.addEventListener('keydown', closeOnEscape);
+ return () => document.removeEventListener('keydown', closeOnEscape);
+ }, [instructionsOpen]);
+
async function toggleConnection() {
const action = connectionAction({ connected, selectedTag, configExists: state?.configExists });
if (action?.type === 'stop') return onStop();
@@ -170,11 +242,11 @@ export function ClientOverviewPage({
}
}
- async function refreshInfo() {
+ async function refreshSubscription() {
const startedAt = performance.now();
setRefreshingInfo(true);
try {
- await onRefreshSubscriptionInfo();
+ await onRefreshSubscription();
setUsageUpdated(false);
requestAnimationFrame(() => setUsageUpdated(true));
setTimeout(() => setUsageUpdated(false), 900);
@@ -190,8 +262,36 @@ export function ClientOverviewPage({
}
}
+ function toggleInstruction(id) {
+ const update = () => flushSync(() => {
+ setOpenInstructionId((current) => current === id ? '' : id);
+ });
+
+ if (!document.startViewTransition || matchMedia('(prefers-reduced-motion: reduce)').matches) {
+ update();
+ return;
+ }
+
+ document.startViewTransition(update);
+ }
+
return (
+ {hasSubscription &&
}
{showPower && (
@@ -304,10 +404,10 @@ export function ClientOverviewPage({
+ {hasSubscription && }
+
);
}
diff --git a/src/web/instructions.js b/src/web/instructions.js
new file mode 100644
index 0000000..7f60a4c
--- /dev/null
+++ b/src/web/instructions.js
@@ -0,0 +1,87 @@
+export function instructionBlocks({ isGateway, host, port }) {
+ const httpProxy = `http://${host}:${port}`;
+ const socksProxy = `socks5://${host}:${port}`;
+
+ return [
+ {
+ id: 'about',
+ label: 'Основы',
+ title: isGateway ? 'Gateway и прокси' : 'Что такое прокси',
+ summary: isGateway
+ ? 'Два способа направить трафик через это устройство.'
+ : 'Способ направить трафик выбранного приложения через VPN.',
+ paragraphs: isGateway
+ ? [
+ `Gateway (${host}) заменяет основной шлюз устройства и проводит через VPN весь его интернет-трафик.`,
+ `Gateway Proxy (${host}:${port}) работает точечно: его указывают в браузере, редакторе или другом приложении. Если приложение не умеет работать с прокси, можно использовать ProxyBridge.`,
+ ]
+ : [
+ `Локальный прокси (${host}:${port}) не перенаправляет приложения автоматически. Каждое приложение должно использовать этот адрес само — напрямую или через ProxyBridge.`,
+ 'HTTP обычно проще для браузеров и редакторов. SOCKS5 подходит приложениям и инструментам, которым нужен более универсальный транспорт.',
+ ],
+ },
+ {
+ id: 'proxybridge',
+ label: 'Приложения',
+ title: 'ProxyBridge',
+ summary: 'Направляет через прокси отдельные приложения, даже если у них нет своей настройки.',
+ steps: [
+ {
+ link: ['Установите ProxyBridge', 'https://interceptsuite.com/download/proxybridge'],
+ after: ' с официальной страницы проекта.',
+ },
+ `Добавьте прокси типа SOCKS5: сервер ${host}, порт ${port}.`,
+ 'Создайте правило, выберите нужное приложение и действие Proxy.',
+ 'Включите ProxyBridge и запустите приложение заново.',
+ ],
+ note: 'Не добавляйте в правило сам ProxyBridge и VPN-клиент: это может создать прокси-цикл.',
+ },
+ {
+ id: 'switchyomega',
+ label: 'Браузер',
+ title: 'SwitchyOmega',
+ summary: 'Переключает прокси-профили только для браузера.',
+ steps: [
+ {
+ link: ['Установите расширение', 'https://chromewebstore.google.com/detail/proxy-switchyomega/padekgcemlokbadohgkifijomclgjgif'],
+ after: ' и откройте его настройки.',
+ },
+ 'Создайте профиль Proxy Profile.',
+ `Выберите HTTP, укажите сервер ${host} и порт ${port}.`,
+ 'Создайте профиль Auto Switch, выберите созданный прокси для нужных сайтов, а для остальных оставьте Direct.',
+ 'Если сайт не загрузился, откройте SwitchyOmega: расширение покажет проблемные ресурсы. Добавьте домен текущего сайта в Auto Switch и назначьте ему прокси-профиль.',
+ ],
+ note: 'Проект больше не поддерживается. Используйте его только если расширение уже подходит вашему браузеру.',
+ },
+ {
+ id: 'vscode',
+ label: 'Редактор',
+ title: 'Visual Studio Code',
+ summary: 'VS Code использует системный прокси или адрес, переданный при запуске.',
+ steps: [
+ 'Если прокси уже настроен в системе, полностью перезапустите VS Code — обычно он подхватит настройку автоматически.',
+ 'Для отдельного запуска через SOCKS5 используйте команду ниже.',
+ {
+ link: ['Документация VS Code', 'https://code.visualstudio.com/docs/setup/network'],
+ after: ' описывает также системный прокси, HTTP и параметры исключений.',
+ },
+ ],
+ code: `code --proxy-server="${socksProxy}"`,
+ note: `VS Code не поддерживает логин и пароль для SOCKS5. Здесь прокси ${host}:${port} локальный и без авторизации, поэтому этот вариант подходит. HTTP-адрес ${httpProxy} остаётся альтернативой.`,
+ },
+ ...(isGateway ? [{
+ id: 'router',
+ label: 'Вся сеть',
+ title: 'Заменить Gateway в роутере',
+ summary: 'Роутер будет выдавать этот Gateway устройствам как основной шлюз.',
+ steps: [
+ `Закрепите за Gateway постоянный адрес ${host} в настройках DHCP роутера.`,
+ 'Откройте настройки локальной сети или DHCP. Не меняйте шлюз WAN/интернет-подключения.',
+ `В поле Default Gateway, Router или Основной шлюз укажите ${host}.`,
+ 'Сохраните настройки и переподключите устройства к сети, чтобы они получили новый маршрут.',
+ `Для отката верните в это поле локальный адрес самого роутера вместо ${host}.`,
+ ],
+ note: 'Gateway и устройства должны находиться в одной локальной сети. Сначала проверьте настройку на одном устройстве вручную.',
+ }] : []),
+ ];
+}
diff --git a/src/web/styles.css b/src/web/styles.css
index 796763b..554f15b 100644
--- a/src/web/styles.css
+++ b/src/web/styles.css
@@ -79,6 +79,7 @@ p {
}
.client-shell {
+ position: relative;
width: min(100%, 1100px);
display: grid;
gap: 12px;
@@ -86,6 +87,352 @@ p {
color: var(--client-text);
}
+.client-instructions-toggle {
+ position: fixed;
+ top: 50%;
+ left: 14px;
+ z-index: 30;
+ width: 42px;
+ height: 54px;
+ display: grid;
+ place-items: center;
+ padding: 0;
+ border: 0;
+ background: transparent;
+ color: var(--client-muted);
+ cursor: pointer;
+ transform: translateY(-50%);
+ transition: transform 440ms cubic-bezier(0.16, 1, 0.3, 1);
+}
+
+.client-instructions-toggle svg {
+ position: relative;
+ z-index: 1;
+ width: 23px;
+ height: 18px;
+ fill: none;
+ stroke: currentColor;
+ stroke-width: 1.7;
+ stroke-linecap: round;
+ stroke-linejoin: round;
+ overflow: visible;
+ transition: transform 500ms cubic-bezier(0.16, 1, 0.3, 1);
+}
+
+.client-instructions-toggle path {
+ opacity: 0;
+ transform: translateX(-5px);
+ transition: opacity 260ms ease, transform 480ms cubic-bezier(0.16, 1, 0.3, 1);
+}
+
+.client-instructions-toggle path:last-child {
+ opacity: 1;
+ transform: translateX(-4px);
+}
+
+.client-instructions-toggle span {
+ position: absolute;
+ left: 48px;
+ width: max-content;
+ color: var(--client-text);
+ font: 700 10px/1.2 'JetBrains Mono', 'SF Mono', ui-monospace, Menlo, monospace;
+ letter-spacing: 0.04em;
+ opacity: 0;
+ filter: blur(5px);
+ pointer-events: none;
+ transform: translateX(-8px);
+ transition: opacity 350ms ease, filter 350ms ease, transform 500ms cubic-bezier(0.16, 1, 0.3, 1);
+}
+
+.client-instructions-toggle:hover,
+.client-instructions-toggle:focus-visible {
+ outline: none;
+ transform: translateY(-50%) translateX(3px);
+}
+
+.client-instructions-toggle:hover path,
+.client-instructions-toggle:focus-visible path {
+ opacity: 1;
+ transform: translateX(0);
+}
+
+.client-instructions-toggle:hover span,
+.client-instructions-toggle:focus-visible span {
+ opacity: 1;
+ filter: blur(0);
+ transform: translateX(0);
+}
+
+.client-instructions-toggle.is-open svg {
+ transform: rotate(180deg);
+}
+
+.client-instructions-toggle.is-open path {
+ opacity: 0;
+}
+
+.client-instructions-toggle.is-open path:first-child {
+ opacity: 1;
+ transform: translateX(4px);
+}
+
+.client-instructions-toggle.is-open span {
+ opacity: 0;
+ visibility: hidden;
+}
+
+.client-instructions {
+ position: fixed;
+ inset: 0 auto 0 0;
+ z-index: 20;
+ width: min(470px, 100vw);
+ overflow-y: auto;
+ background: color-mix(in oklch, var(--client-bg) 99%, var(--client-panel));
+ color: var(--client-text);
+ box-shadow: 26px 0 72px oklch(0.09 0.015 145 / 0.12);
+ opacity: 0;
+ visibility: hidden;
+ transform: translateX(-104%);
+ transition: transform 760ms cubic-bezier(0.16, 1, 0.3, 1), opacity 500ms ease, visibility 0s 760ms;
+}
+
+.client-instructions.is-open {
+ opacity: 1;
+ visibility: visible;
+ transform: translateX(0);
+ transition-delay: 0s;
+}
+
+.client-instructions-sheet {
+ min-height: 100%;
+ padding: 54px 34px 72px 72px;
+ font-family: 'JetBrains Mono', 'SF Mono', ui-monospace, Menlo, monospace;
+}
+
+.client-instructions-header {
+ display: grid;
+ gap: 9px;
+ margin: 0 8px 38px;
+}
+
+.client-instructions-header span,
+.client-instruction-summary > span {
+ color: var(--client-muted);
+ font-size: 9px;
+ font-weight: 700;
+ letter-spacing: 0.08em;
+ text-transform: uppercase;
+}
+
+.client-instructions-header h2 {
+ margin: 0;
+ font-size: 22px;
+ letter-spacing: -0.045em;
+}
+
+.client-instructions-intro {
+ display: grid;
+ gap: 12px;
+ margin-top: 15px;
+ color: var(--client-muted);
+ font-size: 11px;
+ line-height: 1.7;
+}
+
+.client-instructions-intro p {
+ margin: 0;
+}
+
+.client-instruction-list {
+ display: grid;
+ gap: 15px;
+}
+
+.client-instruction-block {
+ border: 0;
+ border-radius: 22px;
+ background: color-mix(in oklch, var(--client-panel) 56%, var(--client-bg));
+ box-shadow: 0 14px 36px oklch(0.1 0.015 145 / 0.065);
+ transition: background 300ms ease, box-shadow 500ms ease, transform 500ms cubic-bezier(0.16, 1, 0.3, 1);
+}
+
+.client-instruction-block:nth-child(even) {
+ margin-left: 12px;
+}
+
+.client-instruction-block:nth-child(3n) {
+ margin-right: 9px;
+}
+
+.client-instruction-block:hover,
+.client-instruction-block.is-open {
+ background: color-mix(in oklch, var(--client-panel) 68%, var(--client-bg));
+ box-shadow: 0 20px 48px oklch(0.1 0.015 145 / 0.095);
+ transform: translateY(-2px);
+}
+
+.client-instruction-block.is-open {
+ animation: client-instruction-promote 520ms cubic-bezier(0.16, 1, 0.3, 1) both;
+}
+
+@keyframes client-instruction-promote {
+ from { opacity: 0.72; transform: translateY(10px); }
+ to { opacity: 1; transform: translateY(-2px); }
+}
+
+::view-transition-old(root),
+::view-transition-new(root) {
+ animation: none;
+ mix-blend-mode: normal;
+}
+
+::view-transition-group(instruction-proxybridge),
+::view-transition-group(instruction-switchyomega),
+::view-transition-group(instruction-vscode),
+::view-transition-group(instruction-router) {
+ animation-duration: 560ms;
+ animation-timing-function: cubic-bezier(0.16, 1, 0.3, 1);
+}
+
+.client-instruction-summary {
+ position: relative;
+ width: 100%;
+ display: grid;
+ gap: 7px;
+ padding: 21px 46px 21px 22px;
+ border: 0;
+ background: transparent;
+ color: inherit;
+ font: inherit;
+ text-align: left;
+ cursor: pointer;
+}
+
+.client-instruction-summary:focus-visible {
+ outline: none;
+}
+
+.client-instruction-block:has(.client-instruction-summary:focus-visible) {
+ background: color-mix(in oklch, var(--client-panel) 72%, var(--client-bg));
+ box-shadow: 0 20px 48px oklch(0.1 0.015 145 / 0.11);
+}
+
+.client-instruction-summary > i {
+ position: absolute;
+ top: 27px;
+ right: 20px;
+ width: 12px;
+ height: 12px;
+}
+
+.client-instruction-summary > i::before,
+.client-instruction-summary > i::after {
+ content: '';
+ position: absolute;
+ top: 5px;
+ left: 0;
+ width: 12px;
+ height: 1px;
+ background: var(--client-muted);
+ transform-origin: center;
+ transition: transform 520ms cubic-bezier(0.16, 1, 0.3, 1), opacity 360ms ease;
+}
+
+.client-instruction-summary > i::after {
+ transform: rotate(90deg);
+}
+
+.client-instruction-block.is-open .client-instruction-summary > i::after {
+ opacity: 0;
+ transform: rotate(180deg);
+}
+
+.client-instruction-block.is-open .client-instruction-summary > i::before {
+ transform: rotate(180deg);
+}
+
+.client-instruction-summary strong {
+ font-size: 14px;
+}
+
+.client-instruction-summary small {
+ max-width: 54ch;
+ color: var(--client-muted);
+ font-size: 10px;
+ line-height: 1.55;
+}
+
+.client-instruction-reveal {
+ display: grid;
+ grid-template-rows: 0fr;
+ opacity: 0;
+ filter: blur(4px);
+ transition: grid-template-rows 600ms cubic-bezier(0.16, 1, 0.3, 1), opacity 420ms ease, filter 500ms ease;
+}
+
+.client-instruction-block.is-open .client-instruction-reveal {
+ grid-template-rows: 1fr;
+ opacity: 1;
+ filter: blur(0);
+}
+
+.client-instruction-body {
+ min-height: 0;
+ overflow: hidden;
+ display: grid;
+ gap: 14px;
+ padding: 0 22px 24px;
+ color: var(--client-muted);
+ font-size: 11px;
+ line-height: 1.7;
+}
+
+.client-instruction-body p,
+.client-instruction-body ol {
+ margin: 0;
+}
+
+.client-instruction-body ol {
+ display: grid;
+ gap: 9px;
+ padding-left: 22px;
+}
+
+.client-instruction-body li::marker {
+ color: var(--client-accent);
+ font-variant-numeric: tabular-nums;
+}
+
+.client-instruction-body code {
+ display: block;
+ overflow-x: auto;
+ padding: 12px 14px;
+ border-radius: 12px;
+ background: color-mix(in oklch, var(--client-control) 84%, transparent);
+ color: var(--client-text);
+ font: 500 11px/1.5 'JetBrains Mono', 'SF Mono', ui-monospace, Menlo, monospace;
+ white-space: nowrap;
+}
+
+.client-instruction-body .client-instruction-note {
+ padding: 11px 13px;
+ border-radius: 12px;
+ background: color-mix(in oklch, var(--client-control) 48%, transparent);
+ color: var(--client-text);
+}
+
+.client-instruction-body a {
+ width: fit-content;
+ color: var(--client-accent);
+ font-weight: 700;
+ text-decoration: none;
+}
+
+.client-instruction-body a:hover {
+ text-decoration: underline;
+ text-underline-offset: 3px;
+}
+
.client-panel {
position: relative;
display: grid;
@@ -899,6 +1246,22 @@ p {
padding: 20px 6px;
}
+ .client-instructions-toggle {
+ left: 8px;
+ }
+
+ .client-instructions-sheet {
+ padding: 40px 18px 60px 58px;
+ }
+
+ .client-instructions-header h2 {
+ font-size: 18px;
+ }
+
+ .client-instruction-block:nth-child(n) {
+ margin-inline: 0;
+ }
+
.client-form {
position: static;
width: 100%;
@@ -927,6 +1290,15 @@ p {
.client-access-tab,
.client-access-point,
.client-copy-feedback,
+ .client-instructions,
+ .client-instructions-toggle,
+ .client-instructions-toggle svg,
+ .client-instructions-toggle path,
+ .client-instructions-toggle span,
+ .client-instruction-block,
+ .client-instruction-reveal,
+ .client-instruction-summary > i::before,
+ .client-instruction-summary > i::after,
.client-subscription-edit,
.client-subscription-edit::after,
.client-subscription-summary,
diff --git a/test/server/subscription.test.js b/test/server/subscription.test.js
index 9b6452e..e28f918 100644
--- a/test/server/subscription.test.js
+++ b/test/server/subscription.test.js
@@ -1,7 +1,10 @@
import assert from 'node:assert/strict';
import test from 'node:test';
-import { parseSubscriptionBody } from '../../src/server/subscription.js';
+import {
+ parseSubscriptionBody,
+ selectRefreshedServer,
+} from '../../src/server/subscription.js';
test('subscription server tags are trimmed for selection', () => {
const { servers } = parseSubscriptionBody(JSON.stringify({
@@ -10,3 +13,19 @@ test('subscription server tags are trimmed for selection', () => {
assert.equal(servers[0].tag, 'de-frankfurt');
});
+
+test('refreshed subscription keeps the selected server when its tag still exists', () => {
+ const servers = [{ tag: 'de' }, { tag: 'nl' }];
+
+ assert.equal(selectRefreshedServer('nl', servers), 'nl');
+});
+
+test('refreshed subscription selects the first server when the old tag disappeared', () => {
+ const servers = [{ tag: 'de' }, { tag: 'nl' }];
+
+ assert.equal(selectRefreshedServer('old-name', servers), 'de');
+});
+
+test('refreshed subscription does not select a server before the first user choice', () => {
+ assert.equal(selectRefreshedServer('', [{ tag: 'de' }]), '');
+});
diff --git a/test/web/client-controls.test.js b/test/web/client-controls.test.js
index 52dc44b..5ce3b07 100644
--- a/test/web/client-controls.test.js
+++ b/test/web/client-controls.test.js
@@ -10,6 +10,7 @@ import {
subscriptionDaysLeft,
subscriptionUsage,
} from '../../src/web/utils/clientControls.js';
+import { instructionBlocks } from '../../src/web/instructions.js';
test('connection button chooses the only valid client action', () => {
assert.deepEqual(connectionAction({ connected: true }), { type: 'stop' });
@@ -92,3 +93,12 @@ test('subscription expiry uses Russian day forms', () => {
assert.equal(subscriptionDaysLeft(new Date('2026-07-13T00:00:00Z'), now), 'осталось 2 дня');
assert.equal(subscriptionDaysLeft(new Date('2026-07-16T00:00:00Z'), now), 'осталось 5 дней');
});
+
+test('gateway receives router instructions while local client does not', () => {
+ const gateway = instructionBlocks({ isGateway: true, host: '192.168.1.20', port: 8080 });
+ const client = instructionBlocks({ isGateway: false, host: '127.0.0.1', port: 8082 });
+
+ assert.equal(gateway.at(-1).id, 'router');
+ assert.equal(client.some((block) => block.id === 'router'), false);
+ assert.match(client.find((block) => block.id === 'vscode').code, /127\.0\.0\.1:8082/);
+});