diff --git a/.codex/skills/design-vpn-client-ui/SKILL.md b/.codex/skills/design-vpn-client-ui/SKILL.md index 42febc4..d2bf230 100644 --- a/.codex/skills/design-vpn-client-ui/SKILL.md +++ b/.codex/skills/design-vpn-client-ui/SKILL.md @@ -33,6 +33,7 @@ Preserve the repo's focused one-screen VPN client language: a centered primary a - 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. +- When the owner explicitly chooses modal treatment for critical confirmations, reuse one accessible full-screen confirmation popup: blur and block the background, reveal from center, then stage text and actions. - 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. - In client-side editors, prefer flat text controls and accessible custom pickers over browser-native menus when the native surface breaks the visual language. Do not append another blank row until the current row is complete. diff --git a/.codex/skills/design-vpn-client-ui/references/motion-and-interaction.md b/.codex/skills/design-vpn-client-ui/references/motion-and-interaction.md index 5451650..b61c231 100644 --- a/.codex/skills/design-vpn-client-ui/references/motion-and-interaction.md +++ b/.codex/skills/design-vpn-client-ui/references/motion-and-interaction.md @@ -87,7 +87,14 @@ Use exponential ease-out curves such as `cubic-bezier(0.16, 1, 0.3, 1)` for arri - When an existing subscription is being edited and the field is idle, use the mode-accent underline as a five-second timeout indicator: start bright, fade to quiet, then restore display mode. - Pause the timeout once the user enters content. - Close and clear unfinished input on outside click or Escape. -- Animate the trash lid independently on hover. Open an inline, non-blocking confirmation surface instead of a browser-native confirm dialog. +- Animate the trash lid independently on hover. Use the shared critical confirmation popup instead of a browser-native confirm dialog. + +## Critical confirmation popup + +- Reserve the blocking popup for explicit destructive or data-loss confirmation. It must cover the viewport, make the background inert, and use `alertdialog` with `aria-modal`. +- Fade and blur the backdrop first, resolve the popup from the center, then reveal its title, description, and actions in a short sequence. +- Put initial focus on the safe action, trap Tab within the popup, let Escape and backdrop click choose the safe action, and restore the invoking focus on close. +- Reuse the same component and motion vocabulary for every critical confirmation. Reduced motion presents the final state immediately. ## First-run initialization 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 dc4795a..5ad27e0 100644 --- a/.codex/skills/design-vpn-client-ui/references/visual-language.md +++ b/.codex/skills/design-vpn-client-ui/references/visual-language.md @@ -48,7 +48,7 @@ Design for a macOS user glancing at a small VPN control surface in a quiet deskt - Use one fast translucent cloud treatment for explanatory tooltips. Place the cloud above its target with strong enough contrast to survive busy content beneath it. - Keep a tooltip as a sibling of the animated icon/button it describes. A tooltip must never rotate, glow, blur, scale, or move with the control. -- Use inline confirmation for destructive subscription removal. Explain that VPN stops and proxy-dependent applications lose connectivity until a new subscription is configured. +- Use the shared full-screen critical confirmation popup for destructive actions and unsaved-data exits. Keep the centered surface flat, with hierarchy from blur, spacing, type, and staged motion rather than borders or rounded cards. - Animate physical icon parts when their function suggests it, such as lifting a trash lid on hover, while keeping hit areas and nearby text fixed. - Keep advanced client editors flat inside their side layer: rows, notes, selectors, and actions should not become nested cards, bordered fields, or raised buttons. Use spacing, type, focus light, and state color for hierarchy. diff --git a/src/shared/versions.js b/src/shared/versions.js index 33fd823..f415153 100644 --- a/src/shared/versions.js +++ b/src/shared/versions.js @@ -1,6 +1,6 @@ export const HARBOR_VERSIONS = Object.freeze({ - macClient: '0.5.0', - gatewayClient: '0.5.0', + macClient: '0.5.1', + gatewayClient: '0.5.1', gatewayBackend: '0.5.0', }); diff --git a/src/web/components/ClientOverviewPage.jsx b/src/web/components/ClientOverviewPage.jsx index 59a7926..6f0c1e4 100644 --- a/src/web/components/ClientOverviewPage.jsx +++ b/src/web/components/ClientOverviewPage.jsx @@ -13,6 +13,7 @@ import { import { formatBytes } from '../utils/format.js'; import { instructionBlocks } from '../instructions.js'; import { createLatestRequest, operationBlocked } from '../state/operations.js'; +import { ConfirmationPopup } from './ConfirmationPopup.jsx'; import { canAppendRouteRule } from '../../shared/routingRules.js'; import { HARBOR_VERSIONS, @@ -326,7 +327,6 @@ function LocalRulesPanel({ blocked, dirty, restartPending, - confirmingClose, error, operations, panelRef, @@ -334,8 +334,6 @@ function LocalRulesPanel({ onChange, onRemove, onRemoveComplete, - onKeepEditing, - onDiscard, onClose, onSave, }) { @@ -373,16 +371,6 @@ function LocalRulesPanel({
- {confirmingClose && ( -
- Есть несохранённые настройки - Закрыть редактор и потерять изменения? -
- - -
-
- )}
Правила
@@ -1014,7 +1002,9 @@ export function ClientOverviewPage({ } return ( -
+
- {confirmingDelete &&
- Необратимое действие -

Удалить подписку?

-

- Harbor остановит VPN и удалит сохранённую подписку. Приложения, настроенные на локальный прокси, не смогут выходить в сеть до добавления новой подписки. -

-
- - -
-
} -
+
rule.removing)} dirty={localRulesDirty} restartPending={state?.route?.localRulesPendingRestart === true} - confirmingClose={confirmingLocalRulesClose} error={error} operations={operations} panelRef={localRulesPanelRef} @@ -1445,12 +1418,31 @@ export function ClientOverviewPage({ onChange={changeLocalRule} onRemove={(index) => removeLocalRule(localRulesDraft[index]._key)} onRemoveComplete={finishRemoveLocalRule} - onKeepEditing={() => setConfirmingLocalRulesClose(false)} - onDiscard={discardLocalRules} onClose={requestCloseLocalRules} onSave={saveLocalRules} />} - + setConfirmingLocalRulesClose(false)} + onConfirm={discardLocalRules} + /> + setConfirmingDelete(false)} + onConfirm={forgetSubscription} + />
); } diff --git a/src/web/components/ConfirmationPopup.jsx b/src/web/components/ConfirmationPopup.jsx new file mode 100644 index 0000000..53fedd4 --- /dev/null +++ b/src/web/components/ConfirmationPopup.jsx @@ -0,0 +1,98 @@ +import React, { useEffect, useRef } from 'react'; +import { createPortal } from 'react-dom'; + +const FOCUSABLE = 'button:not(:disabled), [href], input:not(:disabled), [tabindex]:not([tabindex="-1"])'; + +export function ConfirmationPopup({ + open, + id, + kicker, + title, + description, + cancelLabel, + confirmLabel, + busy = false, + onCancel, + onConfirm, +}) { + const overlayRef = useRef(null); + const dialogRef = useRef(null); + const cancelRef = useRef(null); + const busyRef = useRef(busy); + const onCancelRef = useRef(onCancel); + busyRef.current = busy; + onCancelRef.current = onCancel; + + useEffect(() => { + if (!open) return undefined; + const previousFocus = document.activeElement; + const background = [...(overlayRef.current?.parentElement?.children || [])] + .filter((element) => !element.classList.contains('client-confirmation-popup')) + .map((element) => [element, element.inert]); + background.forEach(([element]) => { element.inert = true; }); + const previousOverflow = document.body.style.overflow; + document.body.style.overflow = 'hidden'; + const frame = requestAnimationFrame(() => cancelRef.current?.focus()); + const onKeyDown = (event) => { + if (event.key === 'Escape' && !busyRef.current) { + event.preventDefault(); + onCancelRef.current(); + return; + } + if (event.key !== 'Tab') return; + const controls = [...(dialogRef.current?.querySelectorAll(FOCUSABLE) || [])]; + if (!controls.length) return; + const first = controls[0]; + const last = controls.at(-1); + if (!dialogRef.current?.contains(document.activeElement)) { + event.preventDefault(); + first.focus(); + } else if (event.shiftKey && document.activeElement === first) { + event.preventDefault(); + last.focus(); + } else if (!event.shiftKey && document.activeElement === last) { + event.preventDefault(); + first.focus(); + } + }; + document.addEventListener('keydown', onKeyDown); + return () => { + cancelAnimationFrame(frame); + document.removeEventListener('keydown', onKeyDown); + background.forEach(([element, inert]) => { element.inert = inert; }); + document.body.style.overflow = previousOverflow; + requestAnimationFrame(() => previousFocus?.focus?.()); + }; + }, [open]); + + return createPortal( +
{ + if (event.target === event.currentTarget && !busy) onCancel(); + }} + > +
+ {kicker && {kicker}} +

{title}

+

{description}

+
+ + +
+
+
, + document.querySelector('.app.client-app') || document.body, + ); +} diff --git a/src/web/styles.css b/src/web/styles.css index e77f9e6..dccce9e 100644 --- a/src/web/styles.css +++ b/src/web/styles.css @@ -1132,7 +1132,6 @@ p { .client-local-rule > button:last-child:focus-visible, .client-local-rule-add:focus-visible, .client-local-rules-save:focus-visible, -.client-local-rules-discard button:focus-visible, .client-local-rules-actions button:focus-visible { outline: 0; color: var(--client-accent); @@ -1203,39 +1202,6 @@ p { transform: none; } -.client-local-rules-discard { - display: grid; - gap: 8px; - color: var(--client-muted); - font-size: 9px; - line-height: 1.55; - animation: client-local-rules-notice 420ms cubic-bezier(0.16, 1, 0.3, 1) both; -} - -.client-local-rules-discard strong { - color: oklch(0.68 0.15 28); - font-size: 10px; -} - -.client-local-rules-discard div { - display: flex; - gap: 12px; -} - -.client-local-rules-discard button { - padding: 4px 0; - border: 0; - background: transparent; - color: var(--client-text); - font-size: 9px; - font-weight: 700; - cursor: pointer; -} - -.client-local-rules-discard .is-danger { - color: oklch(0.68 0.15 28); -} - .client-local-rules-actions { display: flex; justify-content: flex-end; @@ -1888,90 +1854,142 @@ p { transition: opacity 360ms ease, filter 480ms cubic-bezier(0.16, 1, 0.3, 1); } -.client-form.is-confirming-delete .client-form-content { - opacity: 0; - filter: blur(8px); - pointer-events: none; -} - -.client-delete-confirmation { - position: absolute; - top: 50%; - left: 50%; - z-index: 2; - width: 100%; - min-height: 250px; +.client-confirmation-popup { + position: fixed; + inset: 0; + z-index: 100; display: grid; - align-content: center; - gap: 12px; - padding: 34px; - border-radius: 20px; - background: color-mix(in oklch, var(--client-panel) 88%, transparent); - box-shadow: 0 20px 54px oklch(0.08 0.015 145 / 0.12); - backdrop-filter: blur(16px); - transform: translate(-50%, -50%); - animation: client-delete-reveal 480ms cubic-bezier(0.16, 1, 0.3, 1) both; + place-items: center; + padding: 24px; + background: color-mix(in oklch, var(--client-bg) 76%, transparent); + -webkit-backdrop-filter: blur(0); + backdrop-filter: blur(0); + opacity: 0; + visibility: hidden; + pointer-events: none; + transition: opacity 260ms ease, -webkit-backdrop-filter 520ms cubic-bezier(0.16, 1, 0.3, 1), backdrop-filter 520ms cubic-bezier(0.16, 1, 0.3, 1), visibility 0s 520ms; } -.client-delete-confirmation > span { - color: oklch(0.68 0.15 28); - font-size: 10px; +.client-confirmation-popup.is-open { + -webkit-backdrop-filter: blur(18px); + backdrop-filter: blur(18px); + opacity: 1; + visibility: visible; + pointer-events: auto; + transition-delay: 0s; +} + +.client-confirmation-dialog { + width: min(430px, calc(100vw - 48px)); + display: grid; + gap: 14px; + padding: 32px 34px; + background: color-mix(in oklch, var(--client-bg) 82%, transparent); + box-shadow: 0 0 86px color-mix(in oklch, var(--client-bg) 86%, transparent); + color: var(--client-text); + font-family: 'JetBrains Mono', 'SF Mono', ui-monospace, Menlo, monospace; + opacity: 0; + filter: blur(18px); + transform: scale(0.9); + transition: opacity 300ms ease, filter 520ms cubic-bezier(0.16, 1, 0.3, 1), transform 560ms cubic-bezier(0.16, 1, 0.3, 1); +} + +.client-confirmation-popup.is-open .client-confirmation-dialog { + opacity: 1; + filter: blur(0); + transform: scale(1); + transition-delay: 90ms; +} + +.client-confirmation-dialog > * { + opacity: 0; + filter: blur(7px); + transform: translateY(7px); + transition: opacity 260ms ease, filter 420ms cubic-bezier(0.16, 1, 0.3, 1), transform 460ms cubic-bezier(0.16, 1, 0.3, 1); +} + +.client-confirmation-popup.is-open .client-confirmation-kicker { + opacity: 1; + filter: blur(0); + transform: translateY(0); + transition-delay: 170ms; +} + +.client-confirmation-popup.is-open .client-confirmation-dialog h2 { + opacity: 1; + filter: blur(0); + transform: translateY(0); + transition-delay: 210ms; +} + +.client-confirmation-popup.is-open .client-confirmation-dialog p { + opacity: 1; + filter: blur(0); + transform: translateY(0); + transition-delay: 270ms; +} + +.client-confirmation-popup.is-open .client-confirmation-actions { + opacity: 1; + filter: blur(0); + transform: translateY(0); + transition-delay: 330ms; +} + +.client-confirmation-kicker { + color: var(--client-muted); + font-size: 9px; font-weight: 700; letter-spacing: 0.08em; text-transform: uppercase; } -.client-delete-confirmation h2 { - font-size: 22px; - letter-spacing: -0.04em; +.client-confirmation-dialog h2 { + color: oklch(0.68 0.15 28); + font-size: 18px; + letter-spacing: -0.035em; } -.client-delete-confirmation p { +.client-confirmation-dialog p { + max-width: 54ch; color: var(--client-muted); font-size: 11px; line-height: 1.7; } -.client-delete-actions { +.client-confirmation-actions { display: flex; - justify-content: flex-end; - gap: 10px; - margin-top: 10px; + gap: 18px; + margin-top: 4px; } -.client-delete-actions button { - padding: 10px 14px; +.client-confirmation-actions button { + padding: 6px 0; border: 0; - border-radius: 9px; - background: color-mix(in oklch, var(--client-control) 74%, transparent); + background: transparent; color: var(--client-text); - font-size: 11px; + font-size: 10px; font-weight: 700; cursor: pointer; - transition: color 220ms ease, background 220ms ease, transform 220ms ease; + transition: color 200ms ease, filter 300ms ease, transform 300ms cubic-bezier(0.16, 1, 0.3, 1); } -.client-delete-actions button:hover:not(:disabled), -.client-delete-actions button:focus-visible { +.client-confirmation-actions button:hover:not(:disabled), +.client-confirmation-actions button:focus-visible { outline: none; - background: var(--client-control); + filter: drop-shadow(0 0 7px currentColor); transform: translateY(-1px); } -.client-delete-actions .is-danger { +.client-confirmation-actions .is-danger { color: oklch(0.68 0.15 28); } -.client-delete-actions button:disabled { - opacity: 0.45; +.client-confirmation-actions button:disabled { + opacity: 0.4; cursor: wait; } -@keyframes client-delete-reveal { - from { opacity: 0; filter: blur(8px); transform: translate(-50%, -48%) scale(0.97); } - to { opacity: 1; filter: blur(0); transform: translate(-50%, -50%) scale(1); } -} - .client-form.is-waiting { opacity: 0; filter: blur(10px); @@ -2882,7 +2900,6 @@ p { .client-local-rule-add-slot > span, .client-local-rules-save, .client-local-rules-runtime, - .client-local-rules-discard, .client-local-rules-actions button { transition: none; animation: none; @@ -2907,8 +2924,10 @@ p { } .client-form-content, - .client-delete-confirmation, - .client-delete-actions button, + .client-confirmation-popup, + .client-confirmation-dialog, + .client-confirmation-dialog > *, + .client-confirmation-actions button, .client-subscription-delete .client-trash-lid { transition: none; animation: none; diff --git a/test/web/rule-editor-contract.test.js b/test/web/rule-editor-contract.test.js index f205362..ccfdefb 100644 --- a/test/web/rule-editor-contract.test.js +++ b/test/web/rule-editor-contract.test.js @@ -5,6 +5,7 @@ import test from 'node:test'; const root = path.resolve(import.meta.dirname, '../..'); const component = fs.readFileSync(path.join(root, 'src/web/components/ClientOverviewPage.jsx'), 'utf8'); +const popup = fs.readFileSync(path.join(root, 'src/web/components/ConfirmationPopup.jsx'), 'utf8'); const styles = fs.readFileSync(path.join(root, 'src/web/styles.css'), 'utf8'); test('rule editor add latency stays constant and dirty exits are guarded', () => { @@ -14,3 +15,14 @@ test('rule editor add latency stays constant and dirty exits are guarded', () => assert.match(component, /requestCloseLocalRules\(\)/); assert.match(component, /localRulesPendingRestart/); }); + +test('critical confirmations share one accessible blocking popup', () => { + assert.match(component, /id="discard-local-rules"/); + assert.match(component, /id="delete-subscription"/); + assert.doesNotMatch(component, /client-local-rules-discard|client-delete-confirmation/); + assert.match(popup, /role="alertdialog"/); + assert.match(popup, /aria-modal="true"/); + assert.match(popup, /querySelectorAll\(FOCUSABLE\)/); + assert.match(popup, /element\.inert = true/); + assert.match(styles, /\.client-confirmation-popup\.is-open[\s\S]*backdrop-filter: blur\(18px\)/); +});