Polish client duration and tooltip interactions
All checks were successful
Build and Deploy Gateway / build-and-push (push) Successful in 13s
Build and Deploy Gateway / deploy (push) Successful in 1s

This commit is contained in:
2026-07-11 16:12:09 +03:00
parent 85053f9948
commit 322f5a125b
6 changed files with 109 additions and 16 deletions

View File

@@ -3,7 +3,7 @@
<head> <head>
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<link rel="icon" href="/harbor-mark.svg" type="image/svg+xml" /> <link id="harbor-favicon" rel="icon" href="/harbor-connect.svg" type="image/svg+xml" />
<title>Harbor</title> <title>Harbor</title>
</head> </head>
<body> <body>

View File

@@ -0,0 +1,8 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32">
<rect width="32" height="32" rx="9" fill="#101812"/>
<circle cx="16" cy="16" r="11" fill="#56c9bd" opacity=".09"/>
<g fill="none" stroke="#62d6c9" stroke-width="2.6" stroke-linecap="round">
<path d="M16 6.5v9"/>
<path d="M10.1 10.3a8 8 0 1 0 11.8 0"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 341 B

View File

@@ -0,0 +1,8 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32">
<rect width="32" height="32" rx="9" fill="#18150f"/>
<circle cx="16" cy="7" r="2.5" fill="none" stroke="#efad58" stroke-width="2.3"/>
<g fill="none" stroke="#efad58" stroke-width="2.3" stroke-linecap="round" stroke-linejoin="round">
<path d="M16 9.5V25M10.5 14h11"/>
<path d="M16 27c-5 0-8-2.8-9.5-6.5M16 27c5 0 8-2.8 9.5-6.5"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 418 B

View File

@@ -31,7 +31,12 @@ function App() {
}, []); }, []);
useEffect(() => { useEffect(() => {
if (state?.mode) document.title = state.mode === 'gateway' ? 'Harbor Gateway' : 'Harbor Connect'; if (!state?.mode) return;
const isGateway = state.mode === 'gateway';
document.title = isGateway ? 'Harbor Gateway' : 'Harbor Connect';
document.getElementById('harbor-favicon').href = isGateway
? '/harbor-gateway.svg'
: '/harbor-connect.svg';
}, [state?.mode]); }, [state?.mode]);
async function run(action) { async function run(action) {

View File

@@ -14,6 +14,11 @@ import { formatBytes } from '../utils/format.js';
import { instructionBlocks } from '../instructions.js'; import { instructionBlocks } from '../instructions.js';
const SUBSCRIPTION_REVEAL_DELAY_MS = 1350; const SUBSCRIPTION_REVEAL_DELAY_MS = 1350;
const DURATION_MODE_STORAGE_KEY = 'harbor-duration-mode';
function CloudTooltip({ children }) {
return <span className="client-tooltip" role="tooltip">{children}</span>;
}
function InstructionStep({ step }) { function InstructionStep({ step }) {
if (typeof step === 'string') return step; if (typeof step === 'string') return step;
@@ -66,7 +71,7 @@ function InstructionBlock({ block, open, onToggle }) {
function DurationPart({ name, children }) { function DurationPart({ name, children }) {
return ( return (
<span <span
className={`client-duration-part${name.endsWith('-value') ? ' is-value' : ' is-label'}`} className={`client-duration-part client-duration-${name}${name.endsWith('-value') ? ' is-value' : ' is-label'}`}
> >
{children} {children}
</span> </span>
@@ -176,7 +181,13 @@ export function ClientOverviewPage({
const showPower = hasSubscription && Boolean(selectedTag); const showPower = hasSubscription && Boolean(selectedTag);
const canStart = Boolean(selectedTag || state?.configExists); const canStart = Boolean(selectedTag || state?.configExists);
const [now, setNow] = useState(Date.now()); const [now, setNow] = useState(Date.now());
const [durationMode, setDurationMode] = useState('digital'); const [durationMode, setDurationMode] = useState(() => {
try {
return localStorage.getItem(DURATION_MODE_STORAGE_KEY) === 'words' ? 'words' : 'digital';
} catch {
return 'digital';
}
});
const [editingSubscription, setEditingSubscription] = useState(!state?.hasSubscription); const [editingSubscription, setEditingSubscription] = useState(!state?.hasSubscription);
const [subscriptionValidation, setSubscriptionValidation] = useState({ url: '', status: 'idle' }); const [subscriptionValidation, setSubscriptionValidation] = useState({ url: '', status: 'idle' });
const [showIntro, setShowIntro] = useState(!hasSubscription); const [showIntro, setShowIntro] = useState(!hasSubscription);
@@ -449,7 +460,15 @@ export function ClientOverviewPage({
} }
function toggleDurationMode() { function toggleDurationMode() {
setDurationMode((mode) => mode === 'digital' ? 'words' : 'digital'); setDurationMode((mode) => {
const nextMode = mode === 'digital' ? 'words' : 'digital';
try {
localStorage.setItem(DURATION_MODE_STORAGE_KEY, nextMode);
} catch {
// The visual preference still works for this session.
}
return nextMode;
});
} }
return ( return (
@@ -502,11 +521,10 @@ export function ClientOverviewPage({
<div className="client-state-detail"> <div className="client-state-detail">
{connected ? ( {connected ? (
<button <button
className={`client-duration-toggle${durationMode === 'words' ? ' is-words' : ''}`} className={`client-duration-toggle client-tooltip-anchor${durationMode === 'words' ? ' is-words' : ''}`}
type="button" type="button"
key="duration" key="duration"
aria-label={durationMode === 'digital' ? 'Показать время словами' : 'Показать цифровой таймер'} aria-label={durationMode === 'digital' ? 'Показать время словами' : 'Показать цифровой таймер'}
title={durationMode === 'digital' ? 'Показать время словами' : 'Показать цифровой таймер'}
onClick={toggleDurationMode} onClick={toggleDurationMode}
> >
<span className="client-duration-stack"> <span className="client-duration-stack">
@@ -516,7 +534,7 @@ export function ClientOverviewPage({
> >
<DurationPart name="hours-value">{String(duration.totalHours).padStart(2, '0')}</DurationPart> <DurationPart name="hours-value">{String(duration.totalHours).padStart(2, '0')}</DurationPart>
:<DurationPart name="minutes-value">{String(duration.minutes.value).padStart(2, '0')}</DurationPart> :<DurationPart name="minutes-value">{String(duration.minutes.value).padStart(2, '0')}</DurationPart>
:<DurationPart name="seconds-value">{String(duration.seconds.value).padStart(2, '0')}</DurationPart> :<DurationPart name="seconds-value" key={`digital-${duration.seconds.value}`}>{String(duration.seconds.value).padStart(2, '0')}</DurationPart>
</time> </time>
<time <time
className={`client-duration${durationMode === 'words' ? ' is-active' : ''}`} className={`client-duration${durationMode === 'words' ? ' is-active' : ''}`}
@@ -531,12 +549,13 @@ export function ClientOverviewPage({
.filter(([name, part]) => part.value || name === 'seconds') .filter(([name, part]) => part.value || name === 'seconds')
.map(([name, part]) => ( .map(([name, part]) => (
<span className="client-duration-unit" data-unit={name} key={name}> <span className="client-duration-unit" data-unit={name} key={name}>
<DurationPart name={`${name}-value`}>{part.value}</DurationPart>{' '} <DurationPart name={`${name}-value`} key={`${name}-${name === 'seconds' ? part.value : 'value'}`}>{part.value}</DurationPart>{' '}
<DurationPart name={`${name}-label`}>{part.label}</DurationPart> <DurationPart name={`${name}-label`}>{part.label}</DurationPart>
</span> </span>
))} ))}
</time> </time>
</span> </span>
<CloudTooltip>{durationMode === 'digital' ? 'Показать время словами' : 'Показать цифровой таймер'}</CloudTooltip>
</button> </button>
) : ( ) : (
<p key="hint"> <p key="hint">
@@ -651,22 +670,21 @@ export function ClientOverviewPage({
<div className="client-subscription-heading"> <div className="client-subscription-heading">
<span>Ваша подписка</span> <span>Ваша подписка</span>
<button <button
className="client-subscription-refresh" className="client-subscription-refresh client-tooltip-anchor"
type="button" type="button"
aria-label="Обновить подписку" aria-label="Обновить подписку"
title="Обновить подписку"
disabled={refreshingInfo} disabled={refreshingInfo}
onClick={refreshSubscription} onClick={refreshSubscription}
> >
<svg viewBox="0 0 24 24" aria-hidden="true"> <svg viewBox="0 0 24 24" aria-hidden="true">
<path d="M21 12a9 9 0 0 0-15.2-6.5L3 8m0-5v5h5M3 12a9 9 0 0 0 15.2 6.5L21 16m0 5v-5h-5" /> <path d="M21 12a9 9 0 0 0-15.2-6.5L3 8m0-5v5h5M3 12a9 9 0 0 0 15.2 6.5L21 16m0 5v-5h-5" />
</svg> </svg>
<CloudTooltip>Обновить подписку</CloudTooltip>
</button> </button>
<button <button
className="client-subscription-delete" className="client-subscription-delete client-tooltip-anchor"
type="button" type="button"
aria-label="Удалить подписку" aria-label="Удалить подписку"
title="Удалить подписку"
disabled={busy} disabled={busy}
onClick={() => setConfirmingDelete(true)} onClick={() => setConfirmingDelete(true)}
> >
@@ -674,6 +692,7 @@ export function ClientOverviewPage({
<path className="client-trash-lid" d="M4 7h16M9 7V4h6v3" /> <path className="client-trash-lid" d="M4 7h16M9 7V4h6v3" />
<path d="m6 7 1 13h10l1-13M10 11v5M14 11v5" /> <path d="m6 7 1 13h10l1-13M10 11v5M14 11v5" />
</svg> </svg>
<CloudTooltip>Удалить подписку</CloudTooltip>
</button> </button>
</div> </div>
<button <button

View File

@@ -346,7 +346,7 @@ p {
gap: 2px; gap: 2px;
padding: 5px 6px; padding: 5px 6px;
border-radius: 5px; border-radius: 5px;
background: color-mix(in oklch, var(--client-panel) 64%, transparent); background: color-mix(in oklch, var(--client-panel) 68%, transparent);
box-shadow: 0 5px 18px oklch(0.08 0.015 145 / 0.1); box-shadow: 0 5px 18px oklch(0.08 0.015 145 / 0.1);
backdrop-filter: blur(8px) saturate(0.9); backdrop-filter: blur(8px) saturate(0.9);
color: var(--client-muted); color: var(--client-muted);
@@ -359,7 +359,7 @@ p {
filter: blur(2px); filter: blur(2px);
pointer-events: none; pointer-events: none;
transform: translateY(3px); transform: translateY(3px);
transition: opacity 240ms ease, filter 320ms ease, transform 400ms cubic-bezier(0.16, 1, 0.3, 1), visibility 0s 400ms; transition: opacity 90ms ease, filter 120ms ease, transform 140ms cubic-bezier(0.16, 1, 0.3, 1), visibility 0s 140ms;
} }
.harbor-mode-tooltip strong { .harbor-mode-tooltip strong {
@@ -373,7 +373,7 @@ p {
visibility: visible; visibility: visible;
filter: blur(0); filter: blur(0);
transform: translateY(0); transform: translateY(0);
transition-delay: 120ms, 120ms, 120ms, 0s; transition-delay: 20ms, 20ms, 20ms, 0s;
} }
.harbor-brand-control:hover .harbor-mode-swap, .harbor-brand-control:hover .harbor-mode-swap,
@@ -968,6 +968,16 @@ p {
content: ' '; content: ' ';
} }
.client-duration-seconds-value {
display: inline-block;
animation: client-second-tick 380ms cubic-bezier(0.16, 1, 0.3, 1);
}
@keyframes client-second-tick {
from { opacity: 0.28; filter: blur(3px); transform: scale(0.9); }
to { opacity: 1; filter: blur(0); transform: scale(1); }
}
.client-duration-toggle { .client-duration-toggle {
width: 290px; width: 290px;
min-height: 24px; min-height: 24px;
@@ -990,6 +1000,44 @@ p {
outline: none; outline: none;
} }
.client-tooltip-anchor {
position: relative;
}
.client-tooltip {
position: absolute;
top: calc(100% + 6px);
left: 50%;
z-index: 12;
width: max-content;
max-width: 220px;
padding: 6px 8px;
border-radius: 8px;
background: color-mix(in oklch, var(--client-panel) 68%, transparent);
box-shadow: 0 7px 22px oklch(0.08 0.015 145 / 0.1);
backdrop-filter: blur(10px) saturate(0.9);
color: var(--client-text);
font: 600 10px/1.35 'JetBrains Mono', 'SF Mono', ui-monospace, Menlo, monospace;
letter-spacing: 0;
text-align: center;
white-space: normal;
opacity: 0;
visibility: hidden;
filter: blur(2px);
pointer-events: none;
transform: translate(-50%, -2px);
transition: opacity 90ms ease, filter 120ms ease, transform 140ms cubic-bezier(0.16, 1, 0.3, 1), visibility 0s 140ms;
}
.client-tooltip-anchor:hover > .client-tooltip,
.client-tooltip-anchor:focus-visible > .client-tooltip {
opacity: 1;
visibility: visible;
filter: blur(0);
transform: translate(-50%, 0);
transition-delay: 20ms, 20ms, 20ms, 0s;
}
.client-power-section p { .client-power-section p {
min-height: 0; min-height: 0;
line-height: 20px; line-height: 20px;
@@ -1855,6 +1903,7 @@ p {
.client-access-point, .client-access-point,
.client-copy-feedback, .client-copy-feedback,
.client-duration, .client-duration,
.client-duration-seconds-value,
.client-instructions, .client-instructions,
.client-instructions-toggle, .client-instructions-toggle,
.client-instructions-toggle svg, .client-instructions-toggle svg,
@@ -1899,6 +1948,10 @@ p {
animation: none; animation: none;
} }
.client-tooltip {
transition: none;
}
.client-state-copy h2, .client-state-copy h2,
.client-state-detail > * { .client-state-detail > * {
animation: none; animation: none;