fix(landing): impeccable audit — фокус на первое неверное поле, POST без JS, отступ под липкую CTA, 320 без скролла, AVIF и sizes

- форма: после «Отправить» с ошибками фокус уходит на первое поле с 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
This commit is contained in:
2026-10-17 17:21:12 +03:00
parent b662e89058
commit be8db997a5
8 changed files with 43 additions and 6 deletions
+2
View File
@@ -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 [];
+6
View File
@@ -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);
+20 -2
View File
@@ -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<HTMLButtonElement>(null);
const formRef = useRef<HTMLFormElement>(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<HTMLElement>('[aria-invalid="true"]');
const target = first?.tagName === 'FIELDSET' ? first.querySelector<HTMLElement>('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}
</p>
{status !== 'success' && (
<form noValidate onSubmit={form.submit} className="flex flex-col gap-5">
<form
ref={formRef}
method="post"
noValidate
onSubmit={(e) => {
setSubmitTick((n) => n + 1);
void form.submit(e);
}}
className="flex flex-col gap-5">
<div className="flex flex-col gap-2">
<label className="field-label" htmlFor={ids.name}>{FORM_TEXT.name.label}</label>
<input
+5
View File
@@ -19,11 +19,15 @@ export function PriceTag({
return (
<p className={`flex items-baseline justify-between gap-4 ${bare ? '' : 'border-t border-ink pt-3'} ${bareOnWide ? 'lg:border-t-0 lg:pt-0' : ''}`}>
{size ? <Size value={size} /> : <span />}
<span className="sr-only"> </span>
<span className="whitespace-nowrap">
<span className={`mono ${large ? 'text-[clamp(2.25rem,1.6rem+2vw,3rem)]' : 'text-price'} font-semibold tracking-[-0.02em]`}>
{price}
</span>
{/* пробелы для скринридера: визуальный отступ задают ml-*, без них читается «32BYN» */}
<span className="sr-only"> </span>
<span className={`mono ${large ? 'text-xl' : 'text-sm'} ml-1.5 font-semibold`}>BYN</span>
{unit && <span className="sr-only"> </span>}
{unit && <span className="ml-2 font-sans text-base font-medium text-muted">{unit}</span>}
</span>
</p>
@@ -38,6 +42,7 @@ function Size({ value }: { value: string }) {
{m ? (
<>
<span>{m[1]}</span>
<span className="sr-only"> </span>
<span className="ml-[0.25em]">{m[2]}</span>
</>
) : (
+7 -1
View File
@@ -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 (
<span className={`stamp ${className ?? ''}`} style={style} data-stamp={animate} data-backing={backing ? '' : undefined} aria-hidden="true">
@@ -13,7 +13,7 @@ export function About() {
src={cooler}
alt={site.images.cooler.alt}
fill
sizes="(min-width: 1280px) 610px, (min-width: 1024px) 48vw, 100vw"
sizes="(min-width: 1280px) 630px, (min-width: 1024px) 48vw, 100vw"
className="object-cover object-[center_40%]"
placeholder="blur"
/>
@@ -13,7 +13,7 @@ export function Catalog({ roastDate }: { roastDate: string }) {
{typo(catalog.title)}
</h2>
<p className="mt-4 max-w-[46ch] text-lead">{typo(catalog.subtitle)}</p>
<div className="mt-10 grid gap-4 md:grid-cols-2 lg:mt-16 lg:grid-cols-3 lg:gap-x-6 lg:gap-y-6">
<div className="mt-10 grid grid-cols-[minmax(0,1fr)] gap-4 md:grid-cols-2 lg:mt-16 lg:grid-cols-3 lg:gap-x-6 lg:gap-y-6">
{packs.map((item) => (
<Label key={item.id} item={item} roastDate={roastDate} />
))}
@@ -32,7 +32,7 @@ export function Hero({ roastDate }: { roastDate: string }) {
fill
priority
fetchPriority="high"
sizes="(min-width: 1280px) 500px, (min-width: 1024px) 38vw, 100vw"
sizes="(min-width: 1280px) 520px, (min-width: 1024px) 38vw, 100vw"
className="object-cover"
placeholder="blur"
/>