feat: tune visual of install instructions page

This commit is contained in:
3252a8
2026-05-23 23:55:06 +03:00
parent 46391b10e2
commit f0eb291f56
3 changed files with 396 additions and 160 deletions
@@ -0,0 +1,74 @@
import { tick } from "svelte";
export function createHeightStageAnimator({
durationMs = 360,
getElement = () => null,
settleDelayMs = 80,
setState = () => {},
} = {}) {
let timer = null;
let frame = null;
let animationId = 0;
function clearPending() {
if (typeof window === "undefined") return;
if (timer) {
window.clearTimeout(timer);
timer = null;
}
if (frame) {
window.cancelAnimationFrame(frame);
frame = null;
}
}
async function animate(applyChange) {
const element = getElement();
if (typeof window === "undefined" || !element) {
applyChange();
return;
}
const currentAnimationId = ++animationId;
clearPending();
const startHeight = Math.ceil(element.getBoundingClientRect().height);
if (!startHeight) {
applyChange();
return;
}
setState({ instant: true, locked: true, style: `height:${startHeight}px;` });
await tick();
if (currentAnimationId !== animationId) return;
void element.offsetHeight;
applyChange();
await tick();
if (currentAnimationId !== animationId) return;
const endHeight = Math.max(1, Math.ceil(element.scrollHeight));
frame = window.requestAnimationFrame(() => {
frame = null;
if (currentAnimationId !== animationId) return;
setState({ instant: false, locked: true, style: `height:${endHeight}px;` });
timer = window.setTimeout(() => {
timer = null;
if (currentAnimationId !== animationId) return;
setState({ instant: false, locked: false, style: "" });
}, durationMs + settleDelayMs);
});
}
function destroy() {
animationId += 1;
clearPending();
setState({ instant: false, locked: false, style: "" });
}
return {
animate,
destroy,
};
}
+93 -1
View File
@@ -2013,6 +2013,43 @@ a {
animation: toast-enter 0.22s ease-out both;
}
.motion-height-stage {
display: grid;
align-content: start;
min-width: 0;
}
.motion-height-stage.motion-height-locked {
overflow: hidden;
transition: height var(--motion-stage-duration, 360ms) cubic-bezier(0.22, 0.72, 0.2, 1);
will-change: height;
}
.motion-height-stage.motion-height-instant {
transition: none;
}
.motion-enter-card {
animation: motion-card-enter var(--motion-enter-duration, 340ms) cubic-bezier(0.22, 0.72, 0.2, 1)
backwards;
animation-delay: var(--motion-delay, 0ms);
transform-origin: var(--motion-origin, center top);
will-change: opacity, transform;
}
.motion-fade-up {
animation: motion-fade-up var(--motion-enter-duration, 160ms) ease-out both;
}
.motion-scale-in {
animation: motion-scale-in var(--motion-enter-duration, 240ms) cubic-bezier(0.22, 0.72, 0.2, 1)
both;
}
.motion-shimmer {
animation: motion-shimmer var(--motion-shimmer-duration, 1.15s) ease-in-out infinite;
}
@keyframes section-enter {
from {
opacity: 0;
@@ -2037,6 +2074,56 @@ a {
}
}
@keyframes motion-card-enter {
from {
opacity: 0;
transform: translate3d(0, 12px, 0) scale(0.985);
}
70% {
opacity: 1;
}
to {
opacity: 1;
transform: translate3d(0, 0, 0) scale(1);
}
}
@keyframes motion-fade-up {
from {
opacity: 0;
transform: translateY(4px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
@keyframes motion-scale-in {
from {
opacity: 0;
transform: scale(0.975);
}
to {
opacity: 1;
transform: scale(1);
}
}
@keyframes motion-shimmer {
0% {
background-position: 120% 0;
}
100% {
background-position: -120% 0;
}
}
@keyframes toast-enter {
from {
opacity: 0;
@@ -2234,11 +2321,16 @@ a {
.dialog-backdrop,
.dialog-card,
.auth-mode-panel,
.motion-enter-card,
.motion-fade-up,
.motion-scale-in,
.motion-shimmer,
.toast {
animation: none !important;
}
.auth-card {
.auth-card,
.motion-height-stage.motion-height-locked {
transition: none !important;
}
}
@@ -1,7 +1,5 @@
<script>
import { getContext, onMount } from "svelte";
import { cubicOut } from "svelte/easing";
import { fade, fly, scale } from "svelte/transition";
import { getContext, onDestroy, onMount } from "svelte";
import QRCode from "qrcode";
import {
ArrowLeft,
@@ -18,6 +16,7 @@
import { Select } from "$components/ui/primitives.js";
import Button from "$components/ui/button.svelte";
import Card from "$components/ui/card.svelte";
import { createHeightStageAnimator } from "$lib/webapp/motion/heightStage.js";
export let currentLang = "ru";
export let telegramPlatform = "";
@@ -32,6 +31,9 @@
export let publicMode = false;
const installGuidesStore = getContext("installGuidesStore");
const STAGE_HEIGHT_ANIMATION_MS = 360;
const CARD_STAGGER_MS = 46;
const QR_DELAY_EXTRA_MS = 90;
const colorTokens = {
amber: "#f59e0b",
blue: "#3b82f6",
@@ -61,11 +63,23 @@
let qrDataUrl = "";
let lastQrValue = "";
let qrRequestId = 0;
let installContentStage;
let stageHeightStyle = "";
let stageHeightLocked = false;
let stageHeightInstant = false;
const installStageAnimator = createHeightStageAnimator({
durationMs: STAGE_HEIGHT_ANIMATION_MS,
getElement: () => installContentStage,
settleDelayMs: QR_DELAY_EXTRA_MS + 80,
setState: setInstallStageState,
});
onMount(() => {
if (!publicMode) installGuidesStore?.load();
});
onDestroy(() => installStageAnimator.destroy());
$: guideState = $installGuidesStore;
$: config = guideState?.config || null;
$: platforms = Object.entries(config?.platforms || {})
@@ -94,6 +108,11 @@
$: apps = selectedPlatform?.apps || [];
$: if (selectedAppIndex >= apps.length) selectedAppIndex = 0;
$: selectedApp = apps[selectedAppIndex] || apps[0] || null;
$: selectedBlocks = Array.isArray(selectedApp?.blocks) ? selectedApp.blocks : [];
$: hasAppSelector = apps.length > 1;
$: stepsDelayOffset = hasAppSelector ? apps.length + 1 : 0;
$: qrDelayIndex = stepsDelayOffset + selectedBlocks.length + 1;
$: installStageStyle = `${stageHeightStyle} --motion-stage-duration:${STAGE_HEIGHT_ANIMATION_MS}ms;`;
$: guideSubscription = guideState?.subscription || subscription || {};
$: finalSubscriptionLink =
guideSubscription?.config_link ||
@@ -132,9 +151,30 @@
return value ? `--install-icon-color:${value};` : "";
}
function setInstallStageState({ instant, locked, style }) {
stageHeightInstant = instant;
stageHeightLocked = locked;
stageHeightStyle = style;
}
function selectPlatform(key) {
selectedPlatformKey = key;
selectedAppIndex = 0;
if (key === selectedPlatformKey) return;
installStageAnimator.animate(() => {
selectedPlatformKey = key;
selectedAppIndex = 0;
});
}
function selectApp(index) {
if (index === selectedAppIndex) return;
installStageAnimator.animate(() => {
selectedAppIndex = index;
});
}
function installMotionStyle(index, extraDelay = 0) {
const delay = Math.max(0, index) * CARD_STAGGER_MS + Math.max(0, extraDelay);
return `--motion-delay:${delay}ms;`;
}
function platformFallbackIcon(key) {
@@ -359,7 +399,7 @@
{#if guideState?.loading && !guideState?.loaded}
<div
class="install-loading"
class="install-loading motion-fade-up"
role="status"
aria-label={t("wa_install_loading", {}, "Loading instructions...")}
>
@@ -375,154 +415,164 @@
</Button>
</Card>
{:else}
{#key selectedPlatformKey}
{#if apps.length > 1}
<section
class="install-selector-block"
aria-label={t("wa_install_app", {}, "App")}
in:fly={{ y: 8, duration: 180, easing: cubicOut }}
out:fade={{ duration: 90 }}
>
<div class="install-section-title">
<span>{t("wa_install_app", {}, "App")}</span>
<div
class="install-content-stage motion-height-stage"
class:motion-height-locked={stageHeightLocked}
class:motion-height-instant={stageHeightInstant}
style={installStageStyle}
bind:this={installContentStage}
>
{#key selectedPlatformKey}
{#if hasAppSelector}
<section
class="install-selector-block motion-enter-card"
style={installMotionStyle(0)}
aria-label={t("wa_install_app", {}, "App")}
>
<div class="install-section-title">
<span>{t("wa_install_app", {}, "App")}</span>
</div>
<div
class="install-apps"
class:apps-mobile-remainder-one={apps.length % 2 === 1}
class:apps-remainder-one={apps.length % 3 === 1}
class:apps-remainder-two={apps.length % 3 === 2}
>
{#each apps as app, index (`${selectedPlatformKey}:${app.name}:${index}`)}
<button
class="install-app-button attention-wrap motion-enter-card"
class:active={selectedAppIndex === index}
class:featured={app.featured}
style={installMotionStyle(index + 1)}
type="button"
onclick={() => selectApp(index)}
>
{#if app.featured}
<AttentionDot class="install-feature-star" />
{/if}
{#if iconSvg(app.svgIconKey)}
<span class="install-svg" aria-hidden="true"
>{@html iconSvg(app.svgIconKey)}</span
>
{/if}
<span>{app.name}</span>
</button>
{/each}
</div>
</section>
{/if}
{/key}
{#if selectedApp}
{#key `${selectedPlatformKey}:${selectedAppIndex}:${selectedApp.name}`}
<section
class="install-steps"
aria-label={selectedApp.name}
style={installMotionStyle(stepsDelayOffset)}
>
{#each selectedBlocks as block, blockIndex (`${selectedPlatformKey}:${selectedApp.name}:${blockIndex}:${localized(block.title)}`)}
<div
class="install-step-motion motion-enter-card"
style={installMotionStyle(stepsDelayOffset + blockIndex)}
>
<Card class="install-step">
<div
class="install-step-icon"
style={iconColorStyle(block.svgIconColor)}
aria-hidden="true"
>
{#if iconSvg(block.svgIconKey)}
{@html iconSvg(block.svgIconKey)}
{:else}
<Check size={19} />
{/if}
</div>
<div class="install-step-body">
<h2>{localized(block.title)}</h2>
<p>{localized(block.description)}</p>
{#if block.buttons?.length}
<div class="install-actions">
{#each block.buttons as button}
<Button
variant={button.type === "copyButton" ? "secondary" : "default"}
onclick={() => handleButton(button)}
>
{#if button.type === "copyButton"}
<Copy size={16} />
{:else}
<ExternalLink size={16} />
{/if}
{localized(button.text)}
</Button>
{/each}
</div>
{/if}
</div>
</Card>
</div>
{/each}
</section>
{/key}
{#if finalSubscriptionLink && !publicMode}
<div
class="install-qr-divider motion-enter-card"
style={installMotionStyle(qrDelayIndex, QR_DELAY_EXTRA_MS)}
aria-hidden="true"
>
<svg viewBox="0 0 240 18" preserveAspectRatio="none">
<path
d="M0 9 Q 4 2 8 9 T 16 9 T 24 9 T 32 9 T 40 9 T 48 9 T 56 9 T 64 9 T 72 9 T 80 9 T 88 9 T 96 9 T 104 9 T 112 9 T 120 9 T 128 9 T 136 9 T 144 9 T 152 9 T 160 9 T 168 9 T 176 9 T 184 9 T 192 9 T 200 9 T 208 9 T 216 9 T 224 9 T 232 9 T 240 9"
/>
</svg>
</div>
<div
class="install-apps"
class:apps-mobile-remainder-one={apps.length % 2 === 1}
class:apps-remainder-one={apps.length % 3 === 1}
class:apps-remainder-two={apps.length % 3 === 2}
class="install-subscription-motion motion-enter-card"
style={installMotionStyle(qrDelayIndex + 1, QR_DELAY_EXTRA_MS)}
>
{#each apps as app, index (`${selectedPlatformKey}:${app.name}:${index}`)}
<button
class="install-app-button attention-wrap"
class:active={selectedAppIndex === index}
class:featured={app.featured}
type="button"
onclick={() => (selectedAppIndex = index)}
in:scale={{
start: 0.97,
duration: 150 + Math.min(index, 5) * 18,
easing: cubicOut,
}}
>
{#if app.featured}
<AttentionDot class="install-feature-star" />
{/if}
{#if iconSvg(app.svgIconKey)}
<span class="install-svg" aria-hidden="true">{@html iconSvg(app.svgIconKey)}</span
>
{/if}
<span>{app.name}</span>
</button>
{/each}
</div>
</section>
{/if}
{/key}
{#if selectedApp}
{#key `${selectedPlatformKey}:${selectedAppIndex}:${selectedApp.name}`}
<section
class="install-steps"
aria-label={selectedApp.name}
in:fly={{ y: 12, duration: 190, easing: cubicOut }}
out:fade={{ duration: 90 }}
>
{#each selectedApp.blocks as block, blockIndex (`${selectedPlatformKey}:${selectedApp.name}:${blockIndex}:${localized(block.title)}`)}
<div
class="install-step-motion"
in:fly={{
y: 10,
duration: 170 + Math.min(blockIndex, 6) * 18,
easing: cubicOut,
}}
>
<Card class="install-step">
<div
class="install-step-icon"
style={iconColorStyle(block.svgIconColor)}
aria-hidden="true"
>
{#if iconSvg(block.svgIconKey)}
{@html iconSvg(block.svgIconKey)}
<Card class="install-subscription-card">
<div class="install-subscription-header">
<div class="install-subscription-header-icon" aria-hidden="true">
<QrCode size={20} />
</div>
<div class="install-subscription-heading">
<h2>{t("wa_install_subscription_link", {}, "Subscription link")}</h2>
<p>
{t(
"wa_install_subscription_link_hint",
{},
"Scan the QR code or copy the link."
)}
</p>
</div>
</div>
<div class="install-subscription-body">
<div class="install-qr-wrap" class:ready={qrDataUrl}>
{#if qrDataUrl}
<img
class="motion-scale-in"
src={qrDataUrl}
alt={t("wa_install_qr_alt", {}, "Subscription QR code")}
/>
{:else}
<Check size={19} />
<span class="install-qr-placeholder motion-shimmer" aria-hidden="true"></span>
{/if}
</div>
<div class="install-step-body">
<h2>{localized(block.title)}</h2>
<p>{localized(block.description)}</p>
{#if block.buttons?.length}
<div class="install-actions">
{#each block.buttons as button}
<Button
variant={button.type === "copyButton" ? "secondary" : "default"}
onclick={() => handleButton(button)}
>
{#if button.type === "copyButton"}
<Copy size={16} />
{:else}
<ExternalLink size={16} />
{/if}
{localized(button.text)}
</Button>
{/each}
</div>
{/if}
<div class="install-actions install-subscription-actions">
<Button variant="secondary" onclick={copySubscriptionLink}>
<Copy size={16} />
{t("wa_install_copy_subscription_link", {}, "Copy link")}
</Button>
<Button onclick={shareInstallGuide}>
<Share2 size={16} />
{t("wa_install_share", {}, "Share")}
</Button>
</div>
</Card>
</div>
{/each}
</section>
{/key}
{#if finalSubscriptionLink && !publicMode}
<div class="install-qr-divider" aria-hidden="true" in:fade={{ duration: 180 }}>
<svg viewBox="0 0 240 18" preserveAspectRatio="none">
<path
d="M0 9 Q 4 2 8 9 T 16 9 T 24 9 T 32 9 T 40 9 T 48 9 T 56 9 T 64 9 T 72 9 T 80 9 T 88 9 T 96 9 T 104 9 T 112 9 T 120 9 T 128 9 T 136 9 T 144 9 T 152 9 T 160 9 T 168 9 T 176 9 T 184 9 T 192 9 T 200 9 T 208 9 T 216 9 T 224 9 T 232 9 T 240 9"
/>
</svg>
</div>
<div
class="install-subscription-motion"
in:fly={{ y: 10, duration: 200, easing: cubicOut }}
>
<Card class="install-subscription-card">
<div class="install-subscription-header">
<div class="install-subscription-header-icon" aria-hidden="true">
<QrCode size={20} />
</div>
<div class="install-subscription-heading">
<h2>{t("wa_install_subscription_link", {}, "Subscription link")}</h2>
<p>
{t("wa_install_subscription_link_hint", {}, "Scan the QR code or copy the link.")}
</p>
</div>
</div>
<div class="install-subscription-body">
{#if qrDataUrl}
<div
class="install-qr-wrap"
in:scale={{ start: 0.96, duration: 180, easing: cubicOut }}
>
<img src={qrDataUrl} alt={t("wa_install_qr_alt", {}, "Subscription QR code")} />
</div>
{/if}
<div class="install-actions install-subscription-actions">
<Button variant="secondary" onclick={copySubscriptionLink}>
<Copy size={16} />
{t("wa_install_copy_subscription_link", {}, "Copy link")}
</Button>
<Button onclick={shareInstallGuide}>
<Share2 size={16} />
{t("wa_install_share", {}, "Share")}
</Button>
</div>
</div>
</Card>
</div>
</Card>
</div>
{/if}
{/if}
{/if}
</div>
{/if}
</main>
@@ -595,13 +645,16 @@
color: var(--muted);
font-size: 13px;
font-weight: 700;
animation: install-loader-enter 0.16s ease-out both;
}
.install-loading :global(.ui-spinner) {
color: var(--accent);
}
.install-content-stage {
gap: 16px;
}
.install-selector-block {
display: grid;
gap: 9px;
@@ -924,16 +977,27 @@
}
.install-qr-wrap {
position: relative;
display: grid;
width: 60%;
aspect-ratio: 1;
min-width: 172px;
max-width: 236px;
place-items: center;
justify-self: center;
padding: 10px;
border: 1px solid var(--border);
border-radius: 8px;
background: transparent;
background: color-mix(in srgb, var(--panel-3) 64%, transparent);
box-sizing: border-box;
overflow: hidden;
transition:
border-color 0.18s ease,
background 0.18s ease;
}
.install-qr-wrap.ready {
background: transparent;
}
.install-qr-wrap img {
@@ -943,6 +1007,20 @@
object-fit: contain;
}
.install-qr-placeholder {
display: block;
width: 100%;
height: 100%;
border-radius: 6px;
background: linear-gradient(
90deg,
color-mix(in srgb, var(--muted) 9%, transparent) 0%,
color-mix(in srgb, var(--muted) 18%, transparent) 42%,
color-mix(in srgb, var(--muted) 9%, transparent) 84%
);
background-size: 220% 100%;
}
:global(.theme-dark) .install-qr-wrap img {
filter: brightness(0) invert(1);
}
@@ -1035,6 +1113,10 @@
transform: none;
}
:global(.install-feature-star.attention-dot) {
animation: none;
}
.install-apps button.active,
.install-apps button:active,
.install-apps button:hover,
@@ -1096,16 +1178,4 @@
transform: scale(1);
}
}
@keyframes install-loader-enter {
from {
opacity: 0;
transform: translateY(4px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
</style>