design: итерация критика 2

Заголовки через typo(): «и» не висит. Подписка: у цены нет своей
линейки, цена и CTA по низу полей. Этикетки сортов делят строки
через subgrid, ступеньки под названием нет; значения полей с
интерлиньяжем 1.3, «•» держится за следующую ноту, вес «250 г» —
число и единица отдельными span. Под ассортиментом строка про
бесплатную доставку от 50 BYN. В тёмной секции строки доставки —
поля с подписями, акцент только на «бесплатно». В подвале слеш
не отрывается.
This commit is contained in:
2026-10-17 17:07:56 +03:00
parent 333870d287
commit 834c4e53a5
12 changed files with 87 additions and 29 deletions
+6
View File
@@ -370,6 +370,9 @@ const martian = Martian_Mono({ subsets: ['cyrillic', 'latin'], variable: '--font
- Поле `spec` (site.ts) только из фактов: Эфиопия — «ОБЖАРКА — светлая», Бразилия — «ДЛЯ — эспрессо», дрип — «КАК — заваривается прямо в чашке», у Колумбии поля нет (в ТЗ нет — не выдумывать). Значение — Golos small 500.
- Строка «ДАТА ОБЖАРКИ»: min-height 72px, `align-items: center`, padding-y 12px; под ней сразу линейка цены (без зазора). Штамп `lg`-строкой шириной 136px; повороты −4°, 3°, −2°, 5° и сдвиги по X 0 / 12 / 4 / 20px (Эфиопия, Колумбия, Бразилия, дрип).
- Лент-наклеек нет (сняты в итерации критика 1).
- На ≥768 карточки сортов делят строки сетки через `grid-template-rows: subgrid` (шапка / поля / цена): линейка под названием на одной высоте у всех; строка «дата обжарки» растягивается, если полей меньше.
- Значения полей — line-height 1.3; «•» между нотами склеен неразрывным пробелом со следующей нотой.
- Вес «250 г»: число и единица — отдельные span, отступ 0.25em через CSS вместо моноширинного пробела.
- Ноты склеены неразрывным пробелом внутри ноты («красное яблоко» не рвётся).
- **Дрип-пакеты:** вариант `Label variant="drip"`: на 1440 горизонтальный (вся ширина, высота ~180px; слева название «Дрип-пакеты» + «ассорти», в центре поля «10 ШТ», справа цена и кнопка), сверху перфорация вместо сплошной линии. На 390 — как обычная этикетка, но ниже (без поля нот). Лента та же.
- Никаких фото внутри этикеток и никаких вложенных карточек.
@@ -451,6 +454,7 @@ const martian = Martian_Mono({ subsets: ['cyrillic', 'latin'], variable: '--font
- **Вид:** одна большая этикетка-абонемент на `kraft`, без ленты. Внутри поля: «СОСТАВ — 2 ПАЧКИ × 250 Г», «КАК ЧАСТО — РАЗ В МЕСЯЦ», «ДОСТАВКА — БЕСПЛАТНО»; справа/снизу PriceTag крупно: «54 BYN / мес» (price ×1.5 = 48px на 1440). SVG: два силуэта пачки (прямоугольник с клапаном, 1.5px ink, без заливки), во второй пачке маленький знак «7 штрихов».
- **390:** H2 → lead → этикетка (поля столбиком, цена внизу) → primary CTA во всю ширину.
- **1440:** H2 + lead в кол. 1–5; этикетка в кол. 6–12: слева поля, справа цена и primary CTA.
- Цена без своей линейки: поля подписки замкнуты линейкой снизу, цена и CTA выровнены по низу полей; примечание — строкой под всей этикеткой.
- **CTA:** CtaButton `site.subscription.cta` (`subscription`, comment «Подписка: 2 пачки в месяц»).
### 5. Кто мы `#about`
@@ -464,6 +468,8 @@ const martian = Martian_Mono({ subsets: ['cyrillic', 'latin'], variable: '--font
- **Фон:** `roast`, текст `cream`. Факты: доставка по Минску в пятницу, от 50 BYN бесплатно, дешевле — стоимость уточнит менеджер; самовывоз ул. Короля, 9; пн–пт 8:00–20:00, сб–вс 10:00–18:00.
- **<768:** фото мешка скрыто (рядом уже охладитель), секция начинается с H2, padding-top 48px. **768–1023:** фото полосой сверху. **≥1024:** фото абсолютно слева на 42% ширины экрана, контент в колонках 7–12 контейнера 1280. **390:** H2 → блок «Доставка» (title; поля этикетки: подпись `muted-on-dark`, значение `cream`, линейки 1px `muted-on-dark`) → блок «Самовывоз»: адрес title, часы таблицей Martian (`<dl>`: «ПН–ПТ 8:00–20:00», «СБ–ВС 10:00–18:00»), ссылка «Открыть в Яндекс Картах» (`https://yandex.by/maps/?text=Минск, ул. Короля, 9`, `target=_blank rel=noopener`) → secondary on-dark CTA.
- **1440:** фото кол. 1–5 на всю высоту секции (bleed влево до края экрана), справа кол. 7–12: H2, два блока рядом (по 3 колонки), CTA снизу.
- Строки доставки — поля с tag-подписью `muted-on-dark` (`site.delivery.delivery.labels`); акцент `cherry-on-dark` только на слове `accent` («бесплатно»).
- Под сеткой ассортимента — строка small `site.catalog.deliveryNote` ссылкой на `#delivery`.
- **Фото:** obV_LM0KjxY (Tina Guina, мешок на тёмном). `sizes="(min-width: 1024px) 40vw, 100vw"`, `object-position: center`, lazy. Alt: «Открытый мешок с кофейным зерном».
- **CTA:** CtaButton secondary on-dark, `interest: 'question'`, без comment (подпись CTAS.question). Акценты (ПТ, «бесплатно») — `cherry-on-dark`.
- Карты-iframe нет (производительность).
+5 -5
View File
@@ -23,12 +23,12 @@ function splitName(name: string): [string, string] {
function Row({ label, tall = false, wide = false, children }: { label: string; tall?: boolean; wide?: boolean; children: React.ReactNode }) {
return (
<div
className={`flex items-center gap-3 border-t border-ink ${tall ? 'min-h-[72px] py-3' : 'min-h-10 py-2'} ${
className={`flex items-center gap-3 border-t border-ink ${tall ? 'min-h-[72px] py-3 md:flex-1' : 'min-h-10 py-2'} ${
wide ? 'lg:first:border-t-0' : ''
}`}
>
<span className="tag w-[6.25rem] flex-none text-muted lg:w-[7.5rem]">{label}</span>
<span className="min-w-0">{children}</span>
<span className="min-w-0 leading-[1.3]">{children}</span>
</div>
);
}
@@ -49,10 +49,10 @@ export function Label({ item, roastDate, wide = false }: { item: Item; roastDate
);
const fields = (
<div className={wide ? 'lg:px-8' : 'mt-auto'}>
<div className={wide ? 'lg:px-8' : 'flex flex-col'}>
{item.notes.length > 0 && (
<Row label={label.notes} wide={wide}>
<span className="tag text-ink">{item.notes.map(keep).join(' • ')}</span>
<span className="tag text-ink">{item.notes.map(keep).join(' •\u00A0')}</span>
</Row>
)}
{item.spec && (
@@ -95,7 +95,7 @@ export function Label({ item, roastDate, wide = false }: { item: Item; roastDate
className={`relative border border-ink bg-kraft p-5 lg:p-6 ${
wide
? 'flex flex-col lg:grid lg:grid-cols-[1fr_1.4fr_1fr] lg:items-center lg:py-8 lg:[&>*+*]:border-l lg:[&>*+*]:border-ink'
: 'flex flex-col'
: 'flex flex-col md:row-span-3 md:grid md:grid-rows-subgrid md:gap-0'
}`}
aria-labelledby={`label-${item.id}`}
>
+22 -2
View File
@@ -5,6 +5,7 @@ export function PriceTag({
unit,
large = false,
bareOnWide = false,
bare = false,
}: {
size?: string;
price: number;
@@ -12,10 +13,12 @@ export function PriceTag({
large?: boolean;
/** без верхней линейки на ≥1024 — у горизонтальной этикетки зоны делят вертикальные линейки */
bareOnWide?: boolean;
/** без верхней линейки совсем — когда цена стоит под своими полями (подписка) */
bare?: boolean;
}) {
return (
<p className={`flex items-baseline justify-between gap-4 border-t border-ink pt-3 ${bareOnWide ? 'lg:border-t-0 lg:pt-0' : ''}`}>
{size ? <span className="mono text-base font-medium whitespace-nowrap">{size}</span> : <span />}
<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="whitespace-nowrap">
<span className={`mono ${large ? 'text-[clamp(2.25rem,1.6rem+2vw,3rem)]' : 'text-price'} font-semibold tracking-[-0.02em]`}>
{price}
@@ -26,3 +29,20 @@ export function PriceTag({
</p>
);
}
/** «250 г»: число и единица отдельными span — моноширинный пробел слишком широкий, отступ задаём сами. */
function Size({ value }: { value: string }) {
const m = /^(\S+)\s+(.+)$/.exec(value);
return (
<span className="mono text-base font-medium whitespace-nowrap">
{m ? (
<>
<span>{m[1]}</span>
<span className="ml-[0.25em]">{m[2]}</span>
</>
) : (
value
)}
</span>
);
}
@@ -20,7 +20,7 @@ export function About() {
</div>
<div className="lg:col-span-5 lg:col-start-8">
<h2 id="about-title" className="text-headline font-extrabold">
{about.title}
{typo(about.title)}
</h2>
<div className="mt-5 flex max-w-[62ch] flex-col gap-4">
{about.text.map((p) => (
@@ -10,10 +10,10 @@ export function Catalog({ roastDate }: { roastDate: string }) {
<section id="coffee" aria-labelledby="coffee-title" className="page-x section-y">
<div className="container-page border-t-2 border-ink pt-10 lg:pt-14">
<h2 id="coffee-title" className="text-headline font-extrabold">
{catalog.title}
{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-6">
<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">
{packs.map((item) => (
<Label key={item.id} item={item} roastDate={roastDate} />
))}
@@ -23,6 +23,11 @@ export function Catalog({ roastDate }: { roastDate: string }) {
</div>
)}
</div>
<p className="mt-6 text-small">
<a href={catalog.deliveryNote.href} className="inline-flex min-h-11 items-center underline">
{typo(catalog.deliveryNote.text)}
</a>
</p>
</div>
</section>
);
@@ -24,21 +24,24 @@ export function Delivery() {
<div className="container-page lg:grid lg:grid-cols-12 lg:gap-x-6">
<div className="lg:col-span-6 lg:col-start-7">
<h2 id="delivery-title" className="text-headline font-extrabold">
{delivery.title}
{typo(delivery.title)}
</h2>
<div className="mt-10 grid gap-10 md:grid-cols-2 md:gap-8 lg:mt-14">
<div>
<h3 className="text-title font-extrabold">{delivery.delivery.title}</h3>
<ul className="mt-5">
{delivery.delivery.lines.map((line) => (
<li key={line} className="border-t border-muted-on-dark py-3 first:border-t-0 first:pt-0">
{typo(line)}
</li>
<h3 className="text-title font-extrabold">{typo(delivery.delivery.title)}</h3>
<dl className="mt-5">
{delivery.delivery.lines.map((line, i) => (
<div key={line} className="flex gap-3 border-t border-muted-on-dark py-3">
<dt className="tag w-[6.5rem] flex-none pt-1 text-muted-on-dark">{delivery.delivery.labels[i]}</dt>
<dd className="min-w-0 leading-[1.4]">
<Accent text={typo(line)} word={delivery.delivery.accent} />
</dd>
</div>
))}
</ul>
</dl>
</div>
<div>
<h3 className="text-title font-extrabold">{pickup.title}</h3>
<h3 className="text-title font-extrabold">{typo(pickup.title)}</h3>
<p className="mt-5">{pickup.address}</p>
<dl className="mt-3">
{pickup.hours.map((row) => (
@@ -70,3 +73,16 @@ export function Delivery() {
</section>
);
}
/** Выделить одно слово акцентом на тёмном. */
function Accent({ text, word }: { text: string; word: string }) {
const i = text.indexOf(word);
if (i === -1) return <>{text}</>;
return (
<>
{text.slice(0, i)}
<span className="text-cherry-on-dark">{word}</span>
{text.slice(i + word.length)}
</>
);
}
@@ -9,7 +9,7 @@ export function Freshness({ week }: { week: RoastWeek }) {
<section id="fresh" aria-labelledby="fresh-title" className="page-x section-y lg:pb-0">
<div className="container-page border-t-2 border-ink pt-10 lg:pt-14">
<h2 id="fresh-title" className="text-headline font-extrabold">
{freshness.title}
{typo(freshness.title)}
</h2>
<p className="mt-4 max-w-[46ch] text-lead">{typo(freshness.why)}</p>
<div className="mt-10 lg:mt-16">
@@ -11,7 +11,7 @@ export function LeadSection() {
<div className="container-page grid gap-10 lg:grid-cols-12 lg:gap-6">
<div className="lg:col-span-5">
<h2 id="lead-title" className="text-headline font-extrabold">
{lead.title}
{typo(lead.title)}
</h2>
<p className="mt-4 max-w-[36ch] text-lead">{typo(lead.subtitle)}</p>
<p className="mt-8 text-small text-muted">{lead.call}</p>
@@ -11,29 +11,29 @@ export function Subscription() {
<div className="container-page grid gap-10 border-t-2 border-ink pt-10 lg:grid-cols-12 lg:gap-6 lg:pt-14">
<div className="lg:col-span-5">
<h2 id="subscription-title" className="text-headline font-extrabold">
{sub.title}
{typo(sub.title)}
</h2>
<p className="mt-4 max-w-[40ch] text-lead">{typo(sub.text)}</p>
</div>
<div className="border border-ink bg-kraft p-5 lg:col-span-7 lg:grid lg:grid-cols-[1.2fr_1fr] lg:gap-10 lg:p-8">
<div className="border border-ink bg-kraft p-5 lg:col-span-7 lg:grid lg:grid-cols-[1.2fr_1fr] lg:gap-x-10 lg:p-8">
<div className="flex flex-col gap-6">
<Packs />
<dl>
{sub.fields.map((field) => (
<div key={field.label} className="flex min-h-10 items-center gap-3 border-t border-ink py-2">
<div key={field.label} className="flex min-h-10 items-center gap-3 border-t border-ink py-2 last:border-b">
<dt className="tag w-[6.25rem] flex-none text-muted lg:w-[7.5rem]">{field.label}</dt>
<dd className="tag text-ink">{typo(field.value)}</dd>
</div>
))}
</dl>
</div>
<div className="mt-4 flex flex-col justify-end gap-6 lg:mt-0">
<PriceTag price={sub.price} unit={sub.priceUnit} large />
<div className="mt-6 flex flex-col justify-end gap-6 lg:mt-0">
<PriceTag price={sub.price} unit={sub.priceUnit} large bare />
<CtaButton interest={sub.cta.interest} comment={sub.cta.comment} className="btn btn-primary w-full">
{sub.cta.label}
</CtaButton>
<p className="-mt-2 text-small text-muted">{typo(sub.note)}</p>
</div>
<p className="mt-4 text-small text-muted lg:col-span-2 lg:mt-6">{typo(sub.note)}</p>
</div>
</div>
</section>
+5
View File
@@ -54,6 +54,8 @@ export const site = {
title: 'Что обжариваем',
subtitle: 'Три сорта в пачках по 250 г и дрип-пакеты.',
label: { brand: 'Кварта · Минск', notes: 'Ноты' },
// строка под сеткой (итерация критика 2, нужна вычитка автора)
deliveryNote: { text: 'Доставка по Минску от 50 BYN — бесплатно', href: '#delivery' },
items: [
{
id: 'ethiopia',
@@ -141,6 +143,9 @@ export const site = {
'От 50 BYN бесплатно.',
'Если заказ меньше, стоимость доставки уточнит менеджер.',
],
// подписи полей к строкам выше и слово-акцент (итерация критика 2, нужна вычитка автора)
labels: ['Когда', 'От 50 BYN', 'Дешевле'],
accent: 'бесплатно',
},
pickup: {
title: 'Самовывоз',
+3
View File
@@ -19,6 +19,9 @@ describe('typo', () => {
it('неразрывный дефис в фамилии', () => {
expect(typo('Scott Soltys-Curry')).toBe('Scott Soltys‑Curry');
});
it('слеш не уходит на новую строку', () => {
expect(typo('Curry / Unsplash')).toBe(`Curry${N}/ Unsplash`);
});
it('keep склеивает фразу', () => {
expect(keep('красное яблоко')).toBe(`красное${N}яблоко`);
});
+5 -2
View File
@@ -1,5 +1,5 @@
// Типографика для вывода: неразрывные пробелы после коротких слов и между числом и единицей,
// неразрывный дефис в двойных латинских фамилиях. Смысл текста не меняется.
// неразрывный дефис в двойных латинских фамилиях, неразрывный пробел перед «/». Смысл текста не меняется.
const NBSP = ' ';
const SHORT = 'а|в|во|и|к|ко|на|не|но|о|об|от|до|по|с|со|у|за|из|для|как|или';
const SHORT_RE = new RegExp(`(^|[\\s(«—–-])(${SHORT}) `, 'giu');
@@ -10,7 +10,10 @@ export function typo(text: string): string {
// дважды: «и в Минске» — после замены первого слова второе стоит уже после NBSP
let out = text.replace(SHORT_RE, `$1$2${NBSP}`);
out = out.replace(new RegExp(`(${NBSP})(${SHORT}) `, 'giu'), `$1$2${NBSP}`);
return out.replace(UNIT_RE, `$1${NBSP}$2`).replace(NAME_HYPHEN_RE, '$1‑$2');
return out
.replace(UNIT_RE, `$1${NBSP}$2`)
.replace(NAME_HYPHEN_RE, '$1\u2011$2')
.replace(/ \//g, `${NBSP}/`);
}
/** Склеить фразу целиком, чтобы она не рвалась: «красное яблоко». */