From 834c4e53a505afed67179bac1aebe3a2c663efcd Mon Sep 17 00:00:00 2001 From: Nikolay Unuchek Date: Sat, 17 Oct 2026 17:07:56 +0300 Subject: [PATCH] =?UTF-8?q?design:=20=D0=B8=D1=82=D0=B5=D1=80=D0=B0=D1=86?= =?UTF-8?q?=D0=B8=D1=8F=20=D0=BA=D1=80=D0=B8=D1=82=D0=B8=D0=BA=D0=B0=202?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Заголовки через typo(): «и» не висит. Подписка: у цены нет своей линейки, цена и CTA по низу полей. Этикетки сортов делят строки через subgrid, ступеньки под названием нет; значения полей с интерлиньяжем 1.3, «•» держится за следующую ноту, вес «250 г» — число и единица отдельными span. Под ассортиментом строка про бесплатную доставку от 50 BYN. В тёмной секции строки доставки — поля с подписями, акцент только на «бесплатно». В подвале слеш не отрывается. --- DESIGN.md | 6 ++++ apps/landing/src/components/Label.tsx | 10 +++--- apps/landing/src/components/PriceTag.tsx | 24 +++++++++++-- .../landing/src/components/sections/About.tsx | 2 +- .../src/components/sections/Catalog.tsx | 9 +++-- .../src/components/sections/Delivery.tsx | 34 ++++++++++++++----- .../src/components/sections/Freshness.tsx | 2 +- .../src/components/sections/LeadSection.tsx | 2 +- .../src/components/sections/Subscription.tsx | 12 +++---- apps/landing/src/content/site.ts | 5 +++ apps/landing/src/lib/typo.test.ts | 3 ++ apps/landing/src/lib/typo.ts | 7 ++-- 12 files changed, 87 insertions(+), 29 deletions(-) diff --git a/DESIGN.md b/DESIGN.md index c991da5..fbb0e87 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -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 (`
`: «ПН–ПТ 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 нет (производительность). diff --git a/apps/landing/src/components/Label.tsx b/apps/landing/src/components/Label.tsx index 42371cc..e4ad3d6 100644 --- a/apps/landing/src/components/Label.tsx +++ b/apps/landing/src/components/Label.tsx @@ -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 (
{label} - {children} + {children}
); } @@ -49,10 +49,10 @@ export function Label({ item, roastDate, wide = false }: { item: Item; roastDate ); const fields = ( -
+
{item.notes.length > 0 && ( - {item.notes.map(keep).join(' • ')} + {item.notes.map(keep).join(' •\u00A0')} )} {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}`} > diff --git a/apps/landing/src/components/PriceTag.tsx b/apps/landing/src/components/PriceTag.tsx index 7c1b85e..e040706 100644 --- a/apps/landing/src/components/PriceTag.tsx +++ b/apps/landing/src/components/PriceTag.tsx @@ -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 ( -

- {size ? {size} : } +

+ {size ? : } {price} @@ -26,3 +29,20 @@ export function PriceTag({

); } + +/** «250 г»: число и единица отдельными span — моноширинный пробел слишком широкий, отступ задаём сами. */ +function Size({ value }: { value: string }) { + const m = /^(\S+)\s+(.+)$/.exec(value); + return ( + + {m ? ( + <> + {m[1]} + {m[2]} + + ) : ( + value + )} + + ); +} diff --git a/apps/landing/src/components/sections/About.tsx b/apps/landing/src/components/sections/About.tsx index 2573194..0d3046f 100644 --- a/apps/landing/src/components/sections/About.tsx +++ b/apps/landing/src/components/sections/About.tsx @@ -20,7 +20,7 @@ export function About() {

- {about.title} + {typo(about.title)}

{about.text.map((p) => ( diff --git a/apps/landing/src/components/sections/Catalog.tsx b/apps/landing/src/components/sections/Catalog.tsx index 459a3d9..8dc7205 100644 --- a/apps/landing/src/components/sections/Catalog.tsx +++ b/apps/landing/src/components/sections/Catalog.tsx @@ -10,10 +10,10 @@ export function Catalog({ roastDate }: { roastDate: string }) {

- {catalog.title} + {typo(catalog.title)}

{typo(catalog.subtitle)}

-
+
{packs.map((item) => (
)}
+

+ + {typo(catalog.deliveryNote.text)} + +

); diff --git a/apps/landing/src/components/sections/Delivery.tsx b/apps/landing/src/components/sections/Delivery.tsx index 885d3d6..412da82 100644 --- a/apps/landing/src/components/sections/Delivery.tsx +++ b/apps/landing/src/components/sections/Delivery.tsx @@ -24,21 +24,24 @@ export function Delivery() {

- {delivery.title} + {typo(delivery.title)}

-

{delivery.delivery.title}

-
    - {delivery.delivery.lines.map((line) => ( -
  • - {typo(line)} -
  • +

    {typo(delivery.delivery.title)}

    +
    + {delivery.delivery.lines.map((line, i) => ( +
    +
    {delivery.delivery.labels[i]}
    +
    + +
    +
    ))} -
+
-

{pickup.title}

+

{typo(pickup.title)}

{pickup.address}

{pickup.hours.map((row) => ( @@ -70,3 +73,16 @@ export function Delivery() { ); } + +/** Выделить одно слово акцентом на тёмном. */ +function Accent({ text, word }: { text: string; word: string }) { + const i = text.indexOf(word); + if (i === -1) return <>{text}; + return ( + <> + {text.slice(0, i)} + {word} + {text.slice(i + word.length)} + + ); +} diff --git a/apps/landing/src/components/sections/Freshness.tsx b/apps/landing/src/components/sections/Freshness.tsx index 4987756..6561e27 100644 --- a/apps/landing/src/components/sections/Freshness.tsx +++ b/apps/landing/src/components/sections/Freshness.tsx @@ -9,7 +9,7 @@ export function Freshness({ week }: { week: RoastWeek }) {

- {freshness.title} + {typo(freshness.title)}

{typo(freshness.why)}

diff --git a/apps/landing/src/components/sections/LeadSection.tsx b/apps/landing/src/components/sections/LeadSection.tsx index b20f439..ab1963b 100644 --- a/apps/landing/src/components/sections/LeadSection.tsx +++ b/apps/landing/src/components/sections/LeadSection.tsx @@ -11,7 +11,7 @@ export function LeadSection() {

- {lead.title} + {typo(lead.title)}

{typo(lead.subtitle)}

{lead.call}

diff --git a/apps/landing/src/components/sections/Subscription.tsx b/apps/landing/src/components/sections/Subscription.tsx index 4ed7fbb..c6811e9 100644 --- a/apps/landing/src/components/sections/Subscription.tsx +++ b/apps/landing/src/components/sections/Subscription.tsx @@ -11,29 +11,29 @@ export function Subscription() {

- {sub.title} + {typo(sub.title)}

{typo(sub.text)}

-
+
{sub.fields.map((field) => ( -
+
{field.label}
{typo(field.value)}
))}
-
- +
+ {sub.cta.label} -

{typo(sub.note)}

+

{typo(sub.note)}

diff --git a/apps/landing/src/content/site.ts b/apps/landing/src/content/site.ts index ac45b36..c7d179d 100644 --- a/apps/landing/src/content/site.ts +++ b/apps/landing/src/content/site.ts @@ -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: 'Самовывоз', diff --git a/apps/landing/src/lib/typo.test.ts b/apps/landing/src/lib/typo.test.ts index 85fbd7e..6cb773a 100644 --- a/apps/landing/src/lib/typo.test.ts +++ b/apps/landing/src/lib/typo.test.ts @@ -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}яблоко`); }); diff --git a/apps/landing/src/lib/typo.ts b/apps/landing/src/lib/typo.ts index f086bf6..81c099f 100644 --- a/apps/landing/src/lib/typo.ts +++ b/apps/landing/src/lib/typo.ts @@ -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}/`); } /** Склеить фразу целиком, чтобы она не рвалась: «красное яблоко». */