design: итерация критика 2
Заголовки через typo(): «и» не висит. Подписка: у цены нет своей линейки, цена и CTA по низу полей. Этикетки сортов делят строки через subgrid, ступеньки под названием нет; значения полей с интерлиньяжем 1.3, «•» держится за следующую ноту, вес «250 г» — число и единица отдельными span. Под ассортиментом строка про бесплатную доставку от 50 BYN. В тёмной секции строки доставки — поля с подписями, акцент только на «бесплатно». В подвале слеш не отрывается.
This commit is contained in:
@@ -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}`}
|
||||
>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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: 'Самовывоз',
|
||||
|
||||
@@ -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}яблоко`);
|
||||
});
|
||||
|
||||
@@ -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}/`);
|
||||
}
|
||||
|
||||
/** Склеить фразу целиком, чтобы она не рвалась: «красное яблоко». */
|
||||
|
||||
Reference in New Issue
Block a user