Add subscription validation and first-run onboarding reveal
This commit is contained in:
@@ -309,6 +309,12 @@ async function handleApi(req, res) {
|
|||||||
return sendJson(res, 200, { success: true, ...parsed });
|
return sendJson(res, 200, { success: true, ...parsed });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (req.method === 'POST' && req.url === '/api/subscription/validate') {
|
||||||
|
const { url = '' } = await readBody(req);
|
||||||
|
const parsed = await fetchSubscription(String(url).trim());
|
||||||
|
return sendJson(res, 200, { success: true, servers: parsed.servers.length });
|
||||||
|
}
|
||||||
|
|
||||||
if (req.method === 'POST' && req.url === '/api/subscription/refresh') {
|
if (req.method === 'POST' && req.url === '/api/subscription/refresh') {
|
||||||
return sendJson(res, 200, await refreshSavedSubscription());
|
return sendJson(res, 200, await refreshSavedSubscription());
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { api } from './api.js';
|
|||||||
import { ClientOverviewPage } from './components/ClientOverviewPage.jsx';
|
import { ClientOverviewPage } from './components/ClientOverviewPage.jsx';
|
||||||
|
|
||||||
function App() {
|
function App() {
|
||||||
|
const previewReady = new URLSearchParams(window.location.search).has('preview-ready');
|
||||||
const [state, setState] = useState(null);
|
const [state, setState] = useState(null);
|
||||||
const [subscriptionUrl, setSubscriptionUrl] = useState('');
|
const [subscriptionUrl, setSubscriptionUrl] = useState('');
|
||||||
const [servers, setServers] = useState([]);
|
const [servers, setServers] = useState([]);
|
||||||
@@ -86,13 +87,13 @@ function App() {
|
|||||||
<div className="app-body client-mode">
|
<div className="app-body client-mode">
|
||||||
<main className="app-main">
|
<main className="app-main">
|
||||||
<ClientOverviewPage
|
<ClientOverviewPage
|
||||||
state={state}
|
state={previewReady ? { ...state, mode: 'client', hasSubscription: true, subscriptionHost: 'harbor.example', selectedTag: 'Amsterdam', proxyPort: 8082 } : state}
|
||||||
busy={busy}
|
busy={busy}
|
||||||
error={error}
|
error={error}
|
||||||
subscriptionUrl={subscriptionUrl}
|
subscriptionUrl={subscriptionUrl}
|
||||||
setSubscriptionUrl={setSubscriptionUrl}
|
setSubscriptionUrl={setSubscriptionUrl}
|
||||||
servers={servers}
|
servers={previewReady ? [{ tag: 'Amsterdam', server: '127.0.0.1', server_port: 443 }] : servers}
|
||||||
pendingTag={pendingTag}
|
pendingTag={previewReady ? 'Amsterdam' : pendingTag}
|
||||||
setPendingTag={setPendingTag}
|
setPendingTag={setPendingTag}
|
||||||
onFetchSubscription={fetchSubscription}
|
onFetchSubscription={fetchSubscription}
|
||||||
onRefreshSubscription={refreshSubscription}
|
onRefreshSubscription={refreshSubscription}
|
||||||
|
|||||||
@@ -16,6 +16,11 @@ async function request(url, options = {}) {
|
|||||||
export const api = {
|
export const api = {
|
||||||
state: () => request('/api/state'),
|
state: () => request('/api/state'),
|
||||||
subscription: {
|
subscription: {
|
||||||
|
validate: (url, signal) => request('/api/subscription/validate', {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({ url }),
|
||||||
|
signal,
|
||||||
|
}),
|
||||||
fetch: (url) => request('/api/subscription/fetch', {
|
fetch: (url) => request('/api/subscription/fetch', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
body: JSON.stringify({ url }),
|
body: JSON.stringify({ url }),
|
||||||
|
|||||||
@@ -13,6 +13,8 @@ import {
|
|||||||
import { formatBytes } from '../utils/format.js';
|
import { formatBytes } from '../utils/format.js';
|
||||||
import { instructionBlocks } from '../instructions.js';
|
import { instructionBlocks } from '../instructions.js';
|
||||||
|
|
||||||
|
const SUBSCRIPTION_REVEAL_DELAY_MS = 1350;
|
||||||
|
|
||||||
function InstructionStep({ step }) {
|
function InstructionStep({ step }) {
|
||||||
if (typeof step === 'string') return step;
|
if (typeof step === 'string') return step;
|
||||||
return (
|
return (
|
||||||
@@ -76,11 +78,15 @@ function HarborBrand({ isGateway }) {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={`harbor-brand is-${product.toLowerCase()}`} aria-label={`Harbor ${product}`}>
|
<div className={`harbor-brand is-${product.toLowerCase()}`} aria-label={`Harbor ${product}`}>
|
||||||
<svg viewBox="0 0 32 32" aria-hidden="true">
|
<div className="harbor-brand-content">
|
||||||
<circle cx="16" cy="6" r="3" />
|
<svg viewBox="0 0 32 32" aria-hidden="true">
|
||||||
<path d="M16 9v15M10 14h12M6 20c1.5 5 5 8 10 8s8.5-3 10-8M6 20l5 1M26 20l-5 1" />
|
<circle cx="16" cy="6" r="3" />
|
||||||
</svg>
|
<path d="M16 9v15M10 14h12" />
|
||||||
<span><strong>Harbor</strong> <em>{product}</em></span>
|
<path className="harbor-anchor-left" d="M16 28C11 28 8.4 25.2 6.3 22v-3.3M3.8 21.4l2.5-2.7 2.5 2.7" />
|
||||||
|
<path className="harbor-anchor-right" d="M16 28C21 28 23.6 25.2 25.7 22v-3.3M23.2 21.4l2.5-2.7 2.5 2.7" />
|
||||||
|
</svg>
|
||||||
|
<span><strong>Harbor</strong> <em>{product}</em></span>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -110,6 +116,9 @@ export function ClientOverviewPage({
|
|||||||
const [now, setNow] = useState(Date.now());
|
const [now, setNow] = useState(Date.now());
|
||||||
const [durationMode, setDurationMode] = useState('digital');
|
const [durationMode, setDurationMode] = useState('digital');
|
||||||
const [editingSubscription, setEditingSubscription] = useState(!state?.hasSubscription);
|
const [editingSubscription, setEditingSubscription] = useState(!state?.hasSubscription);
|
||||||
|
const [subscriptionValidation, setSubscriptionValidation] = useState({ url: '', status: 'idle' });
|
||||||
|
const [showIntro, setShowIntro] = useState(!hasSubscription);
|
||||||
|
const [subscriptionContentReady, setSubscriptionContentReady] = useState(hasSubscription);
|
||||||
const [pings, setPings] = useState({});
|
const [pings, setPings] = useState({});
|
||||||
const [accessTab, setAccessTab] = useState('gateway');
|
const [accessTab, setAccessTab] = useState('gateway');
|
||||||
const [copyFeedback, setCopyFeedback] = useState(null);
|
const [copyFeedback, setCopyFeedback] = useState(null);
|
||||||
@@ -124,6 +133,7 @@ export function ClientOverviewPage({
|
|||||||
const copyTimerRef = useRef(null);
|
const copyTimerRef = useRef(null);
|
||||||
const instructionsPanelRef = useRef(null);
|
const instructionsPanelRef = useRef(null);
|
||||||
const instructionsToggleRef = useRef(null);
|
const instructionsToggleRef = useRef(null);
|
||||||
|
const previousHasSubscriptionRef = useRef(hasSubscription);
|
||||||
const serverKey = servers.map((server) => `${server.tag}:${server.server}:${server.server_port}`).join('|');
|
const serverKey = servers.map((server) => `${server.tag}:${server.server}:${server.server_port}`).join('|');
|
||||||
const gatewayAddress = isGateway ? window.location.hostname : '127.0.0.1';
|
const gatewayAddress = isGateway ? window.location.hostname : '127.0.0.1';
|
||||||
const proxyUrls = localProxyUrls(state?.proxyPort, gatewayAddress);
|
const proxyUrls = localProxyUrls(state?.proxyPort, gatewayAddress);
|
||||||
@@ -143,6 +153,11 @@ export function ClientOverviewPage({
|
|||||||
const orderedInstructionGuides = openInstruction
|
const orderedInstructionGuides = openInstruction
|
||||||
? [openInstruction, ...instructionGuides.filter((block) => block.id !== openInstructionId)]
|
? [openInstruction, ...instructionGuides.filter((block) => block.id !== openInstructionId)]
|
||||||
: instructionGuides;
|
: instructionGuides;
|
||||||
|
const normalizedSubscriptionUrl = subscriptionUrl.trim();
|
||||||
|
const subscriptionValidationStatus = subscriptionValidation.url === normalizedSubscriptionUrl
|
||||||
|
? subscriptionValidation.status
|
||||||
|
: normalizedSubscriptionUrl ? 'checking' : 'idle';
|
||||||
|
const subscriptionWaiting = hasSubscription && !subscriptionContentReady;
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setNow(Date.now());
|
setNow(Date.now());
|
||||||
@@ -181,6 +196,57 @@ export function ClientOverviewPage({
|
|||||||
if (editingSubscription) subscriptionInputRef.current?.focus();
|
if (editingSubscription) subscriptionInputRef.current?.focus();
|
||||||
}, [editingSubscription]);
|
}, [editingSubscription]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!showIntro) return undefined;
|
||||||
|
const timer = setTimeout(() => setShowIntro(false), 1200);
|
||||||
|
return () => clearTimeout(timer);
|
||||||
|
}, [showIntro]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const previouslyHadSubscription = previousHasSubscriptionRef.current;
|
||||||
|
previousHasSubscriptionRef.current = hasSubscription;
|
||||||
|
|
||||||
|
if (!hasSubscription) {
|
||||||
|
setSubscriptionContentReady(false);
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
if (previouslyHadSubscription) {
|
||||||
|
setSubscriptionContentReady(true);
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
if (matchMedia('(prefers-reduced-motion: reduce)').matches) {
|
||||||
|
setSubscriptionContentReady(true);
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
const timer = setTimeout(() => setSubscriptionContentReady(true), SUBSCRIPTION_REVEAL_DELAY_MS);
|
||||||
|
return () => clearTimeout(timer);
|
||||||
|
}, [hasSubscription]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!normalizedSubscriptionUrl) {
|
||||||
|
setSubscriptionValidation({ url: '', status: 'idle' });
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
const controller = new AbortController();
|
||||||
|
setSubscriptionValidation({ url: normalizedSubscriptionUrl, status: 'checking' });
|
||||||
|
const timer = setTimeout(() => {
|
||||||
|
api.subscription.validate(normalizedSubscriptionUrl, controller.signal)
|
||||||
|
.then(() => setSubscriptionValidation({ url: normalizedSubscriptionUrl, status: 'valid' }))
|
||||||
|
.catch(() => {
|
||||||
|
if (!controller.signal.aborted) {
|
||||||
|
setSubscriptionValidation({ url: normalizedSubscriptionUrl, status: 'invalid' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}, 350);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
clearTimeout(timer);
|
||||||
|
controller.abort();
|
||||||
|
};
|
||||||
|
}, [normalizedSubscriptionUrl]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!hasSubscription) {
|
if (!hasSubscription) {
|
||||||
setEditingSubscription(true);
|
setEditingSubscription(true);
|
||||||
@@ -263,7 +329,7 @@ export function ClientOverviewPage({
|
|||||||
|
|
||||||
async function submitSubscription(event) {
|
async function submitSubscription(event) {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
if (!subscriptionUrl.trim()) return;
|
if (subscriptionValidationStatus !== 'valid') return;
|
||||||
await onFetchSubscription();
|
await onFetchSubscription();
|
||||||
setSubscriptionUrl('');
|
setSubscriptionUrl('');
|
||||||
setEditingSubscription(false);
|
setEditingSubscription(false);
|
||||||
@@ -319,9 +385,9 @@ export function ClientOverviewPage({
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="client-shell">
|
<div className={`client-shell${hasSubscription ? '' : ' is-first-run'}${showIntro && !hasSubscription ? ' is-intro' : ''}`}>
|
||||||
<HarborBrand isGateway={isGateway} />
|
<HarborBrand isGateway={isGateway} />
|
||||||
{hasSubscription && <button
|
{hasSubscription && subscriptionContentReady && <button
|
||||||
ref={instructionsToggleRef}
|
ref={instructionsToggleRef}
|
||||||
className={`client-instructions-toggle${instructionsOpen ? ' is-open' : ''}`}
|
className={`client-instructions-toggle${instructionsOpen ? ' is-open' : ''}`}
|
||||||
type="button"
|
type="button"
|
||||||
@@ -468,7 +534,11 @@ export function ClientOverviewPage({
|
|||||||
|
|
||||||
{error && <p className="client-error" role="alert">{error}</p>}
|
{error && <p className="client-error" role="alert">{error}</p>}
|
||||||
|
|
||||||
<div className="client-form">
|
<div
|
||||||
|
className={`client-form${subscriptionWaiting ? ' is-waiting' : ''}`}
|
||||||
|
aria-hidden={subscriptionWaiting}
|
||||||
|
inert={subscriptionWaiting ? true : undefined}
|
||||||
|
>
|
||||||
<div
|
<div
|
||||||
ref={subscriptionRef}
|
ref={subscriptionRef}
|
||||||
className={`client-subscription ${editingSubscription ? 'is-editing' : ''}${editingSubscription && state?.hasSubscription && !subscriptionUrl ? ' is-timing-out' : ''}`}
|
className={`client-subscription ${editingSubscription ? 'is-editing' : ''}${editingSubscription && state?.hasSubscription && !subscriptionUrl ? ' is-timing-out' : ''}`}
|
||||||
@@ -516,7 +586,7 @@ export function ClientOverviewPage({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<form
|
<form
|
||||||
className="client-subscription-edit"
|
className={`client-subscription-edit is-${subscriptionValidationStatus}`}
|
||||||
autoComplete="off"
|
autoComplete="off"
|
||||||
aria-hidden={!editingSubscription}
|
aria-hidden={!editingSubscription}
|
||||||
inert={!editingSubscription ? true : undefined}
|
inert={!editingSubscription ? true : undefined}
|
||||||
@@ -546,13 +616,26 @@ export function ClientOverviewPage({
|
|||||||
{subscriptionDomain(subscriptionUrl)}
|
{subscriptionDomain(subscriptionUrl)}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
{subscriptionUrl.trim() && (
|
{normalizedSubscriptionUrl && (
|
||||||
<button type="submit" aria-label="Сохранить подписку" disabled={busy}>✓</button>
|
<button
|
||||||
|
className="client-subscription-submit"
|
||||||
|
type="submit"
|
||||||
|
aria-label={subscriptionValidationStatus === 'valid'
|
||||||
|
? 'Сохранить подписку'
|
||||||
|
: subscriptionValidationStatus === 'invalid'
|
||||||
|
? 'Ссылка подписки не распознана'
|
||||||
|
: 'Проверяем ссылку подписки'}
|
||||||
|
disabled={busy || subscriptionValidationStatus !== 'valid'}
|
||||||
|
>
|
||||||
|
{subscriptionValidationStatus === 'valid'
|
||||||
|
? '✓'
|
||||||
|
: subscriptionValidationStatus === 'invalid' ? '×' : '…'}
|
||||||
|
</button>
|
||||||
)}
|
)}
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{hasSubscription && hasUsage && (
|
{hasSubscription && subscriptionContentReady && hasUsage && (
|
||||||
<section className={`client-usage${usageUpdated ? ' is-updated' : ''}`} aria-label="Статистика подписки">
|
<section className={`client-usage${usageUpdated ? ' is-updated' : ''}`} aria-label="Статистика подписки">
|
||||||
<span>Использовано</span>
|
<span>Использовано</span>
|
||||||
<strong>
|
<strong>
|
||||||
@@ -582,7 +665,7 @@ export function ClientOverviewPage({
|
|||||||
</section>
|
</section>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{hasSubscription && (
|
{hasSubscription && subscriptionContentReady && (
|
||||||
<section className="client-servers" aria-label="Выберите сервер">
|
<section className="client-servers" aria-label="Выберите сервер">
|
||||||
{!showPower && <span className="client-server-prompt">Выберите сервер</span>}
|
{!showPower && <span className="client-server-prompt">Выберите сервер</span>}
|
||||||
<div
|
<div
|
||||||
@@ -620,7 +703,7 @@ export function ClientOverviewPage({
|
|||||||
</div>
|
</div>
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
{hasSubscription && <aside
|
{hasSubscription && subscriptionContentReady && <aside
|
||||||
ref={instructionsPanelRef}
|
ref={instructionsPanelRef}
|
||||||
id="client-instructions"
|
id="client-instructions"
|
||||||
className={`client-instructions${instructionsOpen ? ' is-open' : ''}`}
|
className={`client-instructions${instructionsOpen ? ' is-open' : ''}`}
|
||||||
|
|||||||
@@ -92,15 +92,43 @@ p {
|
|||||||
|
|
||||||
.harbor-brand {
|
.harbor-brand {
|
||||||
position: fixed;
|
position: fixed;
|
||||||
top: 24px;
|
top: 50%;
|
||||||
left: 28px;
|
left: 50%;
|
||||||
display: flex;
|
z-index: 2;
|
||||||
align-items: center;
|
|
||||||
gap: 9px;
|
|
||||||
color: var(--harbor-word);
|
color: var(--harbor-word);
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
letter-spacing: -0.035em;
|
letter-spacing: -0.035em;
|
||||||
|
transform: translate(-50%, calc(-50% - 25vh)) scale(3);
|
||||||
|
transform-origin: center;
|
||||||
|
transition: transform 1800ms cubic-bezier(0.16, 1, 0.3, 1);
|
||||||
user-select: none;
|
user-select: none;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.client-shell.is-first-run .harbor-brand {
|
||||||
|
transform: translate(-50%, calc(-50% - 72px)) scale(1.35);
|
||||||
|
}
|
||||||
|
|
||||||
|
.harbor-brand-content {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 9px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.client-shell.is-intro .harbor-brand-content {
|
||||||
|
animation: harbor-first-run 1200ms cubic-bezier(0.16, 1, 0.3, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.client-shell.is-intro .client-form {
|
||||||
|
animation: harbor-first-run-form 650ms 720ms cubic-bezier(0.16, 1, 0.3, 1) both;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes harbor-first-run {
|
||||||
|
from { transform: translateY(53px) scale(1.59); }
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes harbor-first-run-form {
|
||||||
|
from { opacity: 0; filter: blur(8px); }
|
||||||
}
|
}
|
||||||
|
|
||||||
.harbor-brand svg {
|
.harbor-brand svg {
|
||||||
@@ -113,6 +141,14 @@ p {
|
|||||||
stroke-linejoin: round;
|
stroke-linejoin: round;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.harbor-brand .harbor-anchor-left {
|
||||||
|
stroke: var(--harbor-connect);
|
||||||
|
}
|
||||||
|
|
||||||
|
.harbor-brand .harbor-anchor-right {
|
||||||
|
stroke: var(--harbor-gateway);
|
||||||
|
}
|
||||||
|
|
||||||
.harbor-brand strong {
|
.harbor-brand strong {
|
||||||
font-weight: 800;
|
font-weight: 800;
|
||||||
}
|
}
|
||||||
@@ -726,7 +762,13 @@ p {
|
|||||||
display: grid;
|
display: grid;
|
||||||
gap: 36px;
|
gap: 36px;
|
||||||
transform: translateX(0);
|
transform: translateX(0);
|
||||||
transition: left 850ms cubic-bezier(0.16, 1, 0.3, 1), transform 850ms cubic-bezier(0.16, 1, 0.3, 1), width 850ms cubic-bezier(0.16, 1, 0.3, 1);
|
transition: left 850ms cubic-bezier(0.16, 1, 0.3, 1), transform 850ms cubic-bezier(0.16, 1, 0.3, 1), width 850ms cubic-bezier(0.16, 1, 0.3, 1), opacity 650ms cubic-bezier(0.16, 1, 0.3, 1), filter 650ms cubic-bezier(0.16, 1, 0.3, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.client-form.is-waiting {
|
||||||
|
opacity: 0;
|
||||||
|
filter: blur(10px);
|
||||||
|
pointer-events: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.client-subscription {
|
.client-subscription {
|
||||||
@@ -842,6 +884,12 @@ p {
|
|||||||
opacity: 0.72;
|
opacity: 0.72;
|
||||||
filter: blur(0.5px);
|
filter: blur(0.5px);
|
||||||
box-shadow: 0 0 8px var(--client-accent), 0 0 18px var(--client-accent-soft);
|
box-shadow: 0 0 8px var(--client-accent), 0 0 18px var(--client-accent-soft);
|
||||||
|
transition: background 500ms ease, box-shadow 500ms ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.client-subscription-edit.is-invalid::after {
|
||||||
|
background: oklch(0.68 0.15 28);
|
||||||
|
box-shadow: 0 0 8px oklch(0.68 0.15 28 / 0.72), 0 0 18px oklch(0.68 0.15 28 / 0.24);
|
||||||
}
|
}
|
||||||
|
|
||||||
.client-subscription.is-timing-out .client-subscription-edit::after {
|
.client-subscription.is-timing-out .client-subscription-edit::after {
|
||||||
@@ -911,7 +959,7 @@ p {
|
|||||||
pointer-events: none;
|
pointer-events: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.client-subscription-edit button {
|
.client-subscription-submit {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
top: 4px;
|
top: 4px;
|
||||||
right: 0;
|
right: 0;
|
||||||
@@ -923,6 +971,32 @@ p {
|
|||||||
color: var(--client-accent);
|
color: var(--client-accent);
|
||||||
font-size: 19px;
|
font-size: 19px;
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: color 250ms ease, filter 500ms ease, opacity 250ms ease, transform 300ms cubic-bezier(0.16, 1, 0.3, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.client-subscription-submit:hover:not(:disabled),
|
||||||
|
.client-subscription-submit:focus-visible {
|
||||||
|
filter: drop-shadow(0 0 5px var(--client-accent)) drop-shadow(0 0 14px var(--client-accent));
|
||||||
|
transform: scale(1.14);
|
||||||
|
}
|
||||||
|
|
||||||
|
.client-subscription-submit:disabled {
|
||||||
|
cursor: default;
|
||||||
|
}
|
||||||
|
|
||||||
|
.client-subscription-edit.is-checking .client-subscription-submit {
|
||||||
|
color: var(--client-muted);
|
||||||
|
animation: client-validation-pulse 900ms ease-in-out infinite alternate;
|
||||||
|
}
|
||||||
|
|
||||||
|
.client-subscription-edit.is-invalid .client-subscription-submit {
|
||||||
|
color: oklch(0.68 0.15 28);
|
||||||
|
opacity: 0.9;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes client-validation-pulse {
|
||||||
|
to { opacity: 0.35; }
|
||||||
}
|
}
|
||||||
|
|
||||||
.client-subscription-heading {
|
.client-subscription-heading {
|
||||||
@@ -1374,10 +1448,7 @@ p {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.harbor-brand {
|
.harbor-brand {
|
||||||
position: absolute;
|
transform: translate(-50%, calc(-50% - 28vh)) scale(2.15);
|
||||||
top: -4px;
|
|
||||||
left: 50%;
|
|
||||||
transform: translateX(-50%);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.client-instructions-toggle {
|
.client-instructions-toggle {
|
||||||
@@ -1437,12 +1508,22 @@ p {
|
|||||||
.client-instruction-summary > i::after,
|
.client-instruction-summary > i::after,
|
||||||
.client-subscription-edit,
|
.client-subscription-edit,
|
||||||
.client-subscription-edit::after,
|
.client-subscription-edit::after,
|
||||||
|
.client-subscription-submit,
|
||||||
.client-subscription-summary,
|
.client-subscription-summary,
|
||||||
.client-subscription-summary strong {
|
.client-subscription-summary strong {
|
||||||
transition: none;
|
transition: none;
|
||||||
animation: none;
|
animation: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.harbor-brand {
|
||||||
|
transition: none;
|
||||||
|
animation: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.harbor-brand-content {
|
||||||
|
animation: none;
|
||||||
|
}
|
||||||
|
|
||||||
.client-state-copy h2,
|
.client-state-copy h2,
|
||||||
.client-state-detail > * {
|
.client-state-detail > * {
|
||||||
animation: none;
|
animation: none;
|
||||||
|
|||||||
Reference in New Issue
Block a user