From e092ac47c51f1de31d8a17a62df3039609e5b8b5 Mon Sep 17 00:00:00 2001 From: Nikolay Unuchek Date: Sat, 17 Oct 2026 17:01:25 +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=201?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Штамп первого экрана на непрозрачной подложке, цифры под мягким фильтром, зазор кнопка→фото 40px. Ленты-наклейки сняты, у штампов на этикетках разные повороты и сдвиги. Ряд цифр в «Кто обжаривает» заменён одной tag-строкой. Дескрипторы сортов — поля этикетки, «в месяц» после цены набрано Golos. Дрип-пакеты на десктопе: три зоны по центру с вертикальными линейками. Доставка в колонках 7–12, на мобиле без фото мешка. Интерес в форме — радиокнопки. Хелпер typo: неразрывные пробелы и дефисы. Скрипт скриншотов: кадр 390 со скроллом. --- DESIGN.md | 30 ++++----- apps/landing/src/app/globals.css | 39 ++++++------ apps/landing/src/components/Label.tsx | 62 ++++++++++++------- apps/landing/src/components/PriceTag.tsx | 29 ++++++--- apps/landing/src/components/Stamp.tsx | 23 +++++-- apps/landing/src/components/WeekStrip.tsx | 5 +- .../landing/src/components/sections/About.tsx | 12 +--- .../src/components/sections/Catalog.tsx | 3 +- .../src/components/sections/Delivery.tsx | 32 +++++----- .../src/components/sections/Footer.tsx | 9 +-- .../src/components/sections/Freshness.tsx | 5 +- apps/landing/src/components/sections/Hero.tsx | 11 ++-- .../src/components/sections/LeadSection.tsx | 13 ++-- .../src/components/sections/Subscription.tsx | 12 ++-- apps/landing/src/content/site.ts | 17 ++--- apps/landing/src/lib/typo.test.ts | 25 ++++++++ apps/landing/src/lib/typo.ts | 19 ++++++ scripts/screenshots.mjs | 9 ++- 18 files changed, 225 insertions(+), 130 deletions(-) create mode 100644 apps/landing/src/lib/typo.test.ts create mode 100644 apps/landing/src/lib/typo.ts diff --git a/DESIGN.md b/DESIGN.md index b3ce82c..c991da5 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -120,10 +120,6 @@ components: textColor: "{colors.ink}" rounded: "{rounded.none}" padding: "20px" - ribbon: - backgroundColor: "{colors.cherry}" - textColor: "{colors.paper}" - typography: "{typography.mono-tag}" --- # Design System: Кварта @@ -150,7 +146,7 @@ components: | **Лента недели** (`WeekStrip`) | 7 дней «буква + число», тонкие линейки, ЧТ и ПТ красным, подписи «обжарка»/«доставка» на линейке, скобка «1–3 день — в чашке» под ПТ–ВС | Блок «почему свежесть» (полная), OG-картинка (статичная), микро-версия в логотипе (7 штрихов, 4-й красный) | | **Штамп** (`Stamp`) | Двойная прямоугольная рамка, текст Martian wdth 112.5 капсом, поворот −7…+5°, смещение, неровность краски | Первый экран (дата ближайшей обжарки, анимирован), поле «дата обжарки» на этикетках (статичный), успех формы «ПРИНЯТО» (анимирован), OG | | **Этикетка** (`Label`) | Крафт-прямоугольник пачки, контент разделён линейками на поля, как на печатной этикетке | Карточки сортов, дрип-пакеты, подписка | -| **Лента-наклейка** (`Ribbon`) | Красная полоса 45° в правом верхнем углу этикетки, «ОБЖАРЕНО В ЧТ» | Только на этикетках ассортимента (4 шт.). Больше нигде | +| ~~Лента-наклейка~~ | Снята в итерации критика 1: повторяла штамп «ЧТ 22.10» на каждой этикетке, 8 красных пятен подряд | Нигде | | **Поле этикетки** | Подпись mono-tag слева, значение справа, линейка 1px снизу | Внутри Label, в блоке подписки, в часах самовывоза | ## Colors @@ -311,7 +307,8 @@ const martian = Martian_Mono({ subsets: ['cyrillic', 'latin'], variable: '--font ### Named Rules **Правило моно-фактов.** Martian Mono — только для чисел, дат, веса, цен, часов, телефона и коротких CAPS-меток. Предложения и заголовки моно не набираются. -**Правило без сплошного капса.** CAPS — только tag, day-letter, stamp и лента-наклейка. Заголовки — в предложном регистре. +**Правило без сплошного капса.** CAPS — только tag, day-letter и stamp. Заголовки — в предложном регистре. +**Правило неразрывных.** Тексты из site.ts выводятся через `typo()` (`lib/typo.ts`): неразрывный пробел после коротких предлогов и союзов, между числом и единицей (5 кг, 250 г, 32 BYN), неразрывный дефис в двойных латинских фамилиях. Фразы, которые не должны рваться (ноты), — через `keep()`. Цена подписки: число и «BYN» моно, единица «в месяц» — Golos 16px `muted`. ## Layout @@ -354,14 +351,14 @@ const martian = Martian_Mono({ subsets: ['cyrillic', 'latin'], variable: '--font ### Label (карточка сорта) ``` ┌─────────────────────────────────┐ ← kraft, 1px ink, overflow hidden -│ КВАРТА · МИНСК ╲лента│ tag, muted +│ КВАРТА · МИНСК │ tag, muted │ │ │ Эфиопия │ title 800 │ Иргачеффе │ small 500, muted │─────────────────────────────────│ 1px ink │ НОТЫ ЦВЕТОЧНЫЙ • БЕРГАМОТ │ tag muted / tag ink │─────────────────────────────────│ -│ ОБЖАРКА СВЕТЛАЯ │ только если есть в ТЗ +│ ОБЖАРКА светлая │ spec из site.ts, если есть │─────────────────────────────────│ │ ДАТА ОБЖАРКИ [ЧТ 22.10] │ поле + Stamp sm │─────────────────────────────────│ @@ -370,8 +367,10 @@ const martian = Martian_Mono({ subsets: ['cyrillic', 'latin'], variable: '--font └─────────────────────────────────┘ ``` - Padding 20px (390) / 24px (1440). Поля этикетки — строки min-height 40px, подпись tag `muted` колонкой 96px, значение tag `ink`. -- Строка третьего поля только из фактов: Эфиопия — «ОБЖАРКА: СВЕТЛАЯ», Бразилия — «ПОД ЭСПРЕССО», у Колумбии строки нет (в ТЗ нет — не выдумывать). -- **Ribbon:** абсолютный блок 160×28px, `cherry`, текст tag `paper` «ОБЖАРЕНО В ЧТ», `rotate(45deg)`, центр в точке (right: −40px, top: 24px), срезан `overflow: hidden` карточки. `aria-hidden`; тот же смысл есть в поле «дата обжарки». +- Поле `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). +- Ноты склеены неразрывным пробелом внутри ноты («красное яблоко» не рвётся). - **Дрип-пакеты:** вариант `Label variant="drip"`: на 1440 горизонтальный (вся ширина, высота ~180px; слева название «Дрип-пакеты» + «ассорти», в центре поля «10 ШТ», справа цена и кнопка), сверху перфорация вместо сплошной линии. На 390 — как обычная этикетка, но ниже (без поля нот). Лента та же. - Никаких фото внутри этикеток и никаких вложенных карточек. @@ -400,7 +399,7 @@ const martian = Martian_Mono({ subsets: ['cyrillic', 'latin'], variable: '--font - **Input/textarea:** высота 52px (textarea min 112px), bg `paper`, рамка 1.5px `muted`, радиус 0, padding 0 14px, Golos 17px (≥16px — iOS не зумит). Плейсхолдер `placeholder`. - **focus-visible / focus:** рамка 2px `ink` (padding компенсировать −0.5px), outline 2px `ink` offset 2px. - **`[data-invalid]`:** рамка 2px `cherry`; текст ошибки `[data-field-error]` — small 500 `cherry-deep`, отступ 6px, слева SVG «!» в квадрате 14px `cherry`. Ошибка не только цветом: есть текст и `aria-describedby` (уже в коде). -- **Интерес (`fieldset[role=radiogroup]`):** три «талона» — `label` min-height 48px, рамка 1.5px `muted`, padding 0 16px; на 390 столбиком на всю ширину, ≥768 в ряд. Нативный radio визуально скрыт (`sr-only`, не `display:none`). `[data-checked]`: bg `ink`, текст `cream`, слева квадрат 10px `cherry`. Фокус: `label:has(:focus-visible)` outline 2px ink offset 2px. `fieldset[data-invalid]` — у всех талонов рамка `cherry`. +- **Интерес (`fieldset[role=radiogroup]`):** три «талона» — `label` min-height 48px, рамка 1.5px `muted`, padding 0 16px; на 390 столбиком на всю ширину, ≥768 в ряд. Нативный radio визуально скрыт (`sr-only`, не `display:none`). `[data-checked]`: bg `ink`, текст `cream`, слева кружок радиокнопки 12px: в idle рамка 1.5px `muted`, в checked — кольцо 1.5px `cream` с точкой `cherry` 6px. Выбор одиночный, поэтому кружок, не квадрат. Фокус: `label:has(:focus-visible)` outline 2px ink offset 2px. `fieldset[data-invalid]` — у всех талонов рамка `cherry`. - **Honeypot:** не трогать. ### Уведомления (`[data-lead-notice]`, по `data-notice` на обёртке) @@ -444,7 +443,8 @@ const martian = Martian_Mono({ subsets: ['cyrillic', 'latin'], variable: '--font - Колумбия: `one_time`, «Колумбия Уила, 250 г» - Бразилия: `one_time`, «Бразилия Серрадо, 250 г» - Дрип: `one_time`, «Дрип-пакеты ассорти, 10 шт» -- Фото нет. SVG: лента, перфорация, стрелка, штампы `sm` (статичные, повороты −4°, 3°, −2°, 5° — разные, чтобы не было копипасты). +- Фото нет. SVG: перфорация, стрелка, штампы (статичные, повороты и сдвиги — см. Label). +- Дрип на ≥1024: три зоны (название / поля / цена и кнопка) по центру по вертикали, между зонами вертикальная линейка 1px `ink`, верхних линеек у первой строки полей и у цены нет. ### 4. Подписка `#subscription` - **Факты:** 2 пачки по 250 г раз в месяц, доставка бесплатно, 54 BYN/мес. @@ -455,14 +455,14 @@ const martian = Martian_Mono({ subsets: ['cyrillic', 'latin'], variable: '--font ### 5. Кто мы `#about` - **Факты:** с 2019 года, основатели Аня и Дмитрий, бывшие бариста, партии по 5 кг. Ничего больше (ни цитат, ни наград). -- **390:** фото на всю ширину 390×260 → H2 → body → строка Martian tag «С 2019 · МИНСК · ПАРТИИ ПО 5 КГ» между линейками 1px. +- **390:** фото на всю ширину 390×260 → H2 → body → одна tag-строка `site.about.factsLine` (Martian 12px `muted`, CAPS) между линейками 1px `ink`, padding-y 12px. Ряд крупных цифр не делаем (hero-метрики). - **1440:** фото кол. 1–6 (3:2, ≈ 610×407), текст кол. 8–12, выровнен по низу фото. - **Фото:** _7CVm353m7A (Scott Soltys-Curry, охладитель ростера). `sizes="(min-width: 1024px) 610px, 100vw"`, `object-position: center 40%`, lazy. Alt: «Свежеобжаренное зерно остывает в охладителе ростера». Подпись/текст не утверждает, что это цех «Кварты». - CTA нет. ### 6. Доставка и самовывоз `#delivery` (тёмная, класс `on-dark`) - **Фон:** `roast`, текст `cream`. Факты: доставка по Минску в пятницу, от 50 BYN бесплатно, дешевле — стоимость уточнит менеджер; самовывоз ул. Короля, 9; пн–пт 8:00–20:00, сб–вс 10:00–18:00. -- **390:** фото мешка 390×220 сверху (на всю ширину) → 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. +- **<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 снизу. - **Фото:** 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`. @@ -470,7 +470,7 @@ const martian = Martian_Mono({ subsets: ['cyrillic', 'latin'], variable: '--font ### 7. Форма `#lead` - Существующий `LeadForm` в блоке paper (см. «Поля формы»). `scroll-mt-4` уже есть. -- **390:** H2 → lead → блок формы на всю ширину → строка «или позвоните» + телефон Martian. +- **390:** H2 → lead → «Или позвоните:» отдельной строкой small `muted` → номер Martian 22px/600 ссылкой `tel:`, `nowrap`, высота 44px → блок формы на всю ширину. - **1440:** кол. 1–5: H2, lead, телефон Martian 22px, часы работы tag; кол. 6–12: блок формы. - Комментарий, пришедший из CTA, виден в textarea — посетитель его видит и может стереть. diff --git a/apps/landing/src/app/globals.css b/apps/landing/src/app/globals.css index bc01ee4..b56007c 100644 --- a/apps/landing/src/app/globals.css +++ b/apps/landing/src/app/globals.css @@ -286,12 +286,14 @@ background-color 120ms ease-out, color 120ms ease-out; } + /* кружок радиокнопки: выбор одиночный */ .talon::before { content: ''; - width: 10px; - height: 10px; + width: 12px; + height: 12px; flex: none; - box-shadow: inset 0 0 0 1.5px currentColor; + border-radius: 50%; + box-shadow: inset 0 0 0 1.5px var(--color-muted); } .talon[data-checked] { background: var(--color-ink); @@ -299,8 +301,8 @@ box-shadow: inset 0 0 0 1.5px var(--color-ink); } .talon[data-checked]::before { - background: var(--color-cherry); - box-shadow: none; + background: radial-gradient(circle, var(--color-cherry) 0 3px, transparent 3.5px); + box-shadow: inset 0 0 0 1.5px var(--color-cream); } .talon:has(:focus-visible) { outline: 2px solid var(--color-ink); @@ -315,19 +317,6 @@ } } - /* Этикетка: лента-наклейка, срезанная краем */ - .ribbon { - position: absolute; - top: 34px; - right: -62px; - width: 220px; - padding: 6px 0; - background: var(--color-cherry); - color: var(--color-paper); - text-align: center; - transform: rotate(45deg); - pointer-events: none; - } .perforation { background-image: linear-gradient(to right, var(--color-ink) 0 6px, transparent 6px 10px); background-size: 10px 1px; @@ -341,7 +330,11 @@ .stamp { display: inline-block; transform: rotate(var(--stamp-rotate, -6deg)); - opacity: 0.92; + } + /* бумажная подложка под штамп на фото: непрозрачная, поворачивается вместе со штампом */ + .stamp[data-backing] { + background: var(--color-paper); + padding: 10px; } .stamp-text { font-family: var(--font-mono); @@ -464,6 +457,10 @@ .week-note { padding: 16px 16px 0; } + .week-note .text-small, + .week-bracket-note .text-small { + font-size: 1rem; + } .week-bracket { grid-column: 5 / 8; grid-row: 2; @@ -491,7 +488,7 @@ transform: rotate(calc(var(--stamp-rotate, -6deg) - 3deg)) scale(1.18); } to { - opacity: 0.92; + opacity: 1; transform: rotate(var(--stamp-rotate, -6deg)) scale(1); } } @@ -512,7 +509,7 @@ @media (prefers-reduced-motion: no-preference) { [data-motion] .stamp[data-stamp='load'] { - animation: stamp-in 380ms var(--ease-out-expo) 250ms both; + animation: stamp-in 380ms var(--ease-out-expo) 150ms both; } .stamp[data-stamp='event'] { animation: stamp-in 380ms var(--ease-out-expo) 80ms both; diff --git a/apps/landing/src/components/Label.tsx b/apps/landing/src/components/Label.tsx index 6c8031c..42371cc 100644 --- a/apps/landing/src/components/Label.tsx +++ b/apps/landing/src/components/Label.tsx @@ -1,21 +1,32 @@ -// Этикетка пачки: крафт, поля через линейки, лента «обжарено в чт», штамп даты обжарки. +// Этикетка пачки: крафт, поля через линейки, штамп даты обжарки. wide — горизонтальная (дрип-пакеты на ≥1024). import { site } from '@/content/site'; +import { keep, typo } from '@/lib/typo'; import { CtaButton } from './CtaButton'; import { PriceTag } from './PriceTag'; import { Stamp } from './Stamp'; type Item = (typeof site.catalog.items)[number]; -const ROTATIONS: Record = { ethiopia: -4, colombia: 3, brazil: -2, drip: 5 }; +// разные повороты и сдвиги, чтобы штампы не выглядели копией +const STAMP: Record = { + ethiopia: { rotate: -4, x: 0 }, + colombia: { rotate: 3, x: 12 }, + brazil: { rotate: -2, x: 4 }, + drip: { rotate: 5, x: 20 }, +}; function splitName(name: string): [string, string] { const i = name.indexOf(' '); return i === -1 ? [name, ''] : [name.slice(0, i), name.slice(i + 1)]; } -function Row({ label, children }: { label: string; children: React.ReactNode }) { +function Row({ label, tall = false, wide = false, children }: { label: string; tall?: boolean; wide?: boolean; children: React.ReactNode }) { return ( -
+
{label} {children}
@@ -25,28 +36,38 @@ function Row({ label, children }: { label: string; children: React.ReactNode }) export function Label({ item, roastDate, wide = false }: { item: Item; roastDate: string; wide?: boolean }) { const [title, region] = splitName(item.name); const { label } = site.catalog; - const extra = item.roast ?? item.brew; + const stamp = STAMP[item.id] ?? { rotate: -3, x: 0 }; const head = ( -
+

{label.brand}

{title} {region && {region}}

- {extra &&

{extra}

}
); const fields = ( -
+
{item.notes.length > 0 && ( - - {item.notes.join(' • ')} + + {item.notes.map(keep).join(' • ')} )} - - + {item.spec && ( + + {typo(item.spec.value)} + + )} + + {site.roastDate.srPrefix}: {roastDate} @@ -55,8 +76,8 @@ export function Label({ item, roastDate, wide = false }: { item: Item; roastDate ); const buy = ( -
- +
+ *+*]:border-l lg:[&>*+*]:border-ink' + : 'flex flex-col' }`} aria-labelledby={`label-${item.id}`} > - {item.id === 'drip' &&
)} diff --git a/apps/landing/src/components/sections/About.tsx b/apps/landing/src/components/sections/About.tsx index 78039be..2573194 100644 --- a/apps/landing/src/components/sections/About.tsx +++ b/apps/landing/src/components/sections/About.tsx @@ -1,5 +1,6 @@ import Image from 'next/image'; import { site } from '@/content/site'; +import { typo } from '@/lib/typo'; import cooler from '../../../public/photos/cooler.jpg'; export function About() { @@ -23,17 +24,10 @@ export function About() {
{about.text.map((p) => ( -

{p}

+

{typo(p)}

))}
-
- {about.facts.map((fact) => ( -
-
{fact.label}
-
{fact.value}
-
- ))} -
+

{typo(about.factsLine)}

diff --git a/apps/landing/src/components/sections/Catalog.tsx b/apps/landing/src/components/sections/Catalog.tsx index 1b4d211..459a3d9 100644 --- a/apps/landing/src/components/sections/Catalog.tsx +++ b/apps/landing/src/components/sections/Catalog.tsx @@ -1,4 +1,5 @@ import { site } from '@/content/site'; +import { typo } from '@/lib/typo'; import { Label } from '../Label'; export function Catalog({ roastDate }: { roastDate: string }) { @@ -11,7 +12,7 @@ export function Catalog({ roastDate }: { roastDate: string }) {

{catalog.title}

-

{catalog.subtitle}

+

{typo(catalog.subtitle)}

{packs.map((item) => (