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
+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}/`);
}
/** Склеить фразу целиком, чтобы она не рвалась: «красное яблоко». */