From be8db997a5a5a79685e84c1e9f5fcc01bcd63e03 Mon Sep 17 00:00:00 2001 From: Nikolay Unuchek Date: Sat, 17 Oct 2026 17:21:12 +0300 Subject: [PATCH] =?UTF-8?q?fix(landing):=20impeccable=20audit=20=E2=80=94?= =?UTF-8?q?=20=D1=84=D0=BE=D0=BA=D1=83=D1=81=20=D0=BD=D0=B0=20=D0=BF=D0=B5?= =?UTF-8?q?=D1=80=D0=B2=D0=BE=D0=B5=20=D0=BD=D0=B5=D0=B2=D0=B5=D1=80=D0=BD?= =?UTF-8?q?=D0=BE=D0=B5=20=D0=BF=D0=BE=D0=BB=D0=B5,=20POST=20=D0=B1=D0=B5?= =?UTF-8?q?=D0=B7=20JS,=20=D0=BE=D1=82=D1=81=D1=82=D1=83=D0=BF=20=D0=BF?= =?UTF-8?q?=D0=BE=D0=B4=20=D0=BB=D0=B8=D0=BF=D0=BA=D1=83=D1=8E=20CTA,=2032?= =?UTF-8?q?0=20=D0=B1=D0=B5=D0=B7=20=D1=81=D0=BA=D1=80=D0=BE=D0=BB=D0=BB?= =?UTF-8?q?=D0=B0,=20AVIF=20=D0=B8=20sizes?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - форма: после «Отправить» с ошибками фокус уходит на первое поле с aria-invalid, скринридер читает подсказку; method=post — без JS имя и телефон не попадают в URL - scroll-padding-bottom < 768: фокус и якоря не прячутся под липкой CTA (WCAG 2.4.11) - штамп на этикетке сжимается на 320 вместо горизонтального скролла (WCAG 1.4.10); на 390 и шире без изменений - цена читается «32 BYN», «250 г» (sr-only пробелы) - sizes фото hero и «Кто обжаривает» по реальной ширине колонки на 1440; AVIF перед WebP --- apps/landing/next.config.ts | 2 ++ apps/landing/src/app/globals.css | 6 +++++ apps/landing/src/components/LeadForm.tsx | 22 +++++++++++++++++-- apps/landing/src/components/PriceTag.tsx | 5 +++++ apps/landing/src/components/Stamp.tsx | 8 ++++++- .../landing/src/components/sections/About.tsx | 2 +- .../src/components/sections/Catalog.tsx | 2 +- apps/landing/src/components/sections/Hero.tsx | 2 +- 8 files changed, 43 insertions(+), 6 deletions(-) diff --git a/apps/landing/next.config.ts b/apps/landing/next.config.ts index a4fbc75..b165982 100644 --- a/apps/landing/next.config.ts +++ b/apps/landing/next.config.ts @@ -6,6 +6,8 @@ const config: NextConfig = { outputFileTracingRoot: path.join(import.meta.dirname, '../..'), transpilePackages: ['@kvarta/shared'], poweredByHeader: false, + // AVIF заметно легче WebP на фото зерна (LCP на мобиле); WebP — запасной + images: { formats: ['image/avif', 'image/webp'] }, // в проде /api/ отдаёт nginx на контейнер api; локально проксируем на dev-сервер api async rewrites() { if (process.env.NODE_ENV === 'production') return []; diff --git a/apps/landing/src/app/globals.css b/apps/landing/src/app/globals.css index b56007c..9ae054f 100644 --- a/apps/landing/src/app/globals.css +++ b/apps/landing/src/app/globals.css @@ -70,6 +70,12 @@ scroll-padding-top: 16px; -webkit-text-size-adjust: 100%; } + /* < 768 снизу липкая CTA (~68px): фокус и якоря не уходят под неё (WCAG 2.4.11) */ + @media (max-width: 767px) { + html { + scroll-padding-bottom: calc(76px + env(safe-area-inset-bottom)); + } + } body { font-family: var(--font-sans); font-size: var(--text-body); diff --git a/apps/landing/src/components/LeadForm.tsx b/apps/landing/src/components/LeadForm.tsx index 1b598cb..dfb92aa 100644 --- a/apps/landing/src/components/LeadForm.tsx +++ b/apps/landing/src/components/LeadForm.tsx @@ -2,7 +2,7 @@ // Разметка формы заявки. Вся логика — в useLeadForm; вёрстку переделает дизайнер. // Состояния для стилей: data-state (idle|submitting|success|error), data-notice, data-invalid, data-checked. import { INTERESTS, INTEREST_LABELS, type LeadField } from '@kvarta/shared/lead'; -import { useEffect, useRef } from 'react'; +import { useEffect, useRef, useState } from 'react'; import { CONTACT_PHONE, FORM_TEXT } from '@/content/form'; import { LEAD_NAME_INPUT_ID } from '@/lib/leadPrefill'; import { Stamp } from './Stamp'; @@ -99,12 +99,22 @@ export function LeadForm({ sendLead, className }: LeadFormProps) { const { values, errors, status, notice } = form; const submitting = status === 'submitting'; const sendAnotherRef = useRef(null); + const formRef = useRef(null); + const [submitTick, setSubmitTick] = useState(0); // кнопка отправки исчезает вместе с формой — переносим фокус, чтобы он не потерялся useEffect(() => { if (status === 'success') sendAnotherRef.current?.focus(); }, [status]); + // после «Отправить» с ошибками фокус — на первое неверное поле: скринридер прочтёт его подсказку (aria-describedby) + useEffect(() => { + if (submitTick === 0 || status === 'submitting') return; + const first = formRef.current?.querySelector('[aria-invalid="true"]'); + const target = first?.tagName === 'FIELDSET' ? first.querySelector('input:checked, input') : first; + target?.focus(); + }, [submitTick, status]); + const fieldProps = (field: LeadField) => ({ 'aria-invalid': errors[field] ? true : undefined, 'aria-describedby': errors[field] ? errorId(field) : undefined, @@ -129,7 +139,15 @@ export function LeadForm({ sendLead, className }: LeadFormProps) { {FORM_TEXT.formTag}

{status !== 'success' && ( -
+ { + setSubmitTick((n) => n + 1); + void form.submit(e); + }} + className="flex flex-col gap-5">
{size ? : } + {price} + {/* пробелы для скринридера: визуальный отступ задают ml-*, без них читается «32BYN» */} + BYN + {unit && } {unit && {unit}}

@@ -38,6 +42,7 @@ function Size({ value }: { value: string }) { {m ? ( <> {m[1]} + {m[2]} ) : ( diff --git a/apps/landing/src/components/Stamp.tsx b/apps/landing/src/components/Stamp.tsx index 00ccbc6..491938a 100644 --- a/apps/landing/src/components/Stamp.tsx +++ b/apps/landing/src/components/Stamp.tsx @@ -30,7 +30,13 @@ export function Stamp({ lines, width = 168, rotate = -6, tone = 'cherry', animat const H = PAD * 2 + heights.reduce((a, b) => a + b, 0); const tops = heights.map((_, i) => PAD + heights.slice(0, i).reduce((a, b) => a + b, 0)); const color = `var(--color-${tone})`; - const style = { '--stamp-rotate': `${rotate}deg`, width, marginLeft: offsetX || undefined } as CSSProperties; + // maxWidth: на 320 этикетка уже штампа — штамп сжимается, а не выталкивает страницу вбок + const style = { + '--stamp-rotate': `${rotate}deg`, + width, + maxWidth: offsetX ? `calc(100% - ${offsetX}px)` : '100%', + marginLeft: offsetX || undefined, + } as CSSProperties; return (