design: итерация критика 1
Штамп первого экрана на непрозрачной подложке, цифры под мягким фильтром, зазор кнопка→фото 40px. Ленты-наклейки сняты, у штампов на этикетках разные повороты и сдвиги. Ряд цифр в «Кто обжаривает» заменён одной tag-строкой. Дескрипторы сортов — поля этикетки, «в месяц» после цены набрано Golos. Дрип-пакеты на десктопе: три зоны по центру с вертикальными линейками. Доставка в колонках 7–12, на мобиле без фото мешка. Интерес в форме — радиокнопки. Хелпер typo: неразрывные пробелы и дефисы. Скрипт скриншотов: кадр 390 со скроллом.
This commit is contained in:
@@ -120,10 +120,6 @@ components:
|
|||||||
textColor: "{colors.ink}"
|
textColor: "{colors.ink}"
|
||||||
rounded: "{rounded.none}"
|
rounded: "{rounded.none}"
|
||||||
padding: "20px"
|
padding: "20px"
|
||||||
ribbon:
|
|
||||||
backgroundColor: "{colors.cherry}"
|
|
||||||
textColor: "{colors.paper}"
|
|
||||||
typography: "{typography.mono-tag}"
|
|
||||||
---
|
---
|
||||||
|
|
||||||
# Design System: Кварта
|
# Design System: Кварта
|
||||||
@@ -150,7 +146,7 @@ components:
|
|||||||
| **Лента недели** (`WeekStrip`) | 7 дней «буква + число», тонкие линейки, ЧТ и ПТ красным, подписи «обжарка»/«доставка» на линейке, скобка «1–3 день — в чашке» под ПТ–ВС | Блок «почему свежесть» (полная), OG-картинка (статичная), микро-версия в логотипе (7 штрихов, 4-й красный) |
|
| **Лента недели** (`WeekStrip`) | 7 дней «буква + число», тонкие линейки, ЧТ и ПТ красным, подписи «обжарка»/«доставка» на линейке, скобка «1–3 день — в чашке» под ПТ–ВС | Блок «почему свежесть» (полная), OG-картинка (статичная), микро-версия в логотипе (7 штрихов, 4-й красный) |
|
||||||
| **Штамп** (`Stamp`) | Двойная прямоугольная рамка, текст Martian wdth 112.5 капсом, поворот −7…+5°, смещение, неровность краски | Первый экран (дата ближайшей обжарки, анимирован), поле «дата обжарки» на этикетках (статичный), успех формы «ПРИНЯТО» (анимирован), OG |
|
| **Штамп** (`Stamp`) | Двойная прямоугольная рамка, текст Martian wdth 112.5 капсом, поворот −7…+5°, смещение, неровность краски | Первый экран (дата ближайшей обжарки, анимирован), поле «дата обжарки» на этикетках (статичный), успех формы «ПРИНЯТО» (анимирован), OG |
|
||||||
| **Этикетка** (`Label`) | Крафт-прямоугольник пачки, контент разделён линейками на поля, как на печатной этикетке | Карточки сортов, дрип-пакеты, подписка |
|
| **Этикетка** (`Label`) | Крафт-прямоугольник пачки, контент разделён линейками на поля, как на печатной этикетке | Карточки сортов, дрип-пакеты, подписка |
|
||||||
| **Лента-наклейка** (`Ribbon`) | Красная полоса 45° в правом верхнем углу этикетки, «ОБЖАРЕНО В ЧТ» | Только на этикетках ассортимента (4 шт.). Больше нигде |
|
| ~~Лента-наклейка~~ | Снята в итерации критика 1: повторяла штамп «ЧТ 22.10» на каждой этикетке, 8 красных пятен подряд | Нигде |
|
||||||
| **Поле этикетки** | Подпись mono-tag слева, значение справа, линейка 1px снизу | Внутри Label, в блоке подписки, в часах самовывоза |
|
| **Поле этикетки** | Подпись mono-tag слева, значение справа, линейка 1px снизу | Внутри Label, в блоке подписки, в часах самовывоза |
|
||||||
|
|
||||||
## Colors
|
## Colors
|
||||||
@@ -311,7 +307,8 @@ const martian = Martian_Mono({ subsets: ['cyrillic', 'latin'], variable: '--font
|
|||||||
|
|
||||||
### Named Rules
|
### Named Rules
|
||||||
**Правило моно-фактов.** Martian Mono — только для чисел, дат, веса, цен, часов, телефона и коротких CAPS-меток. Предложения и заголовки моно не набираются.
|
**Правило моно-фактов.** 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
|
## Layout
|
||||||
|
|
||||||
@@ -354,14 +351,14 @@ const martian = Martian_Mono({ subsets: ['cyrillic', 'latin'], variable: '--font
|
|||||||
### Label (карточка сорта)
|
### Label (карточка сорта)
|
||||||
```
|
```
|
||||||
┌─────────────────────────────────┐ ← kraft, 1px ink, overflow hidden
|
┌─────────────────────────────────┐ ← kraft, 1px ink, overflow hidden
|
||||||
│ КВАРТА · МИНСК ╲лента│ tag, muted
|
│ КВАРТА · МИНСК │ tag, muted
|
||||||
│ │
|
│ │
|
||||||
│ Эфиопия │ title 800
|
│ Эфиопия │ title 800
|
||||||
│ Иргачеффе │ small 500, muted
|
│ Иргачеффе │ small 500, muted
|
||||||
│─────────────────────────────────│ 1px ink
|
│─────────────────────────────────│ 1px ink
|
||||||
│ НОТЫ ЦВЕТОЧНЫЙ • БЕРГАМОТ │ tag muted / tag ink
|
│ НОТЫ ЦВЕТОЧНЫЙ • БЕРГАМОТ │ tag muted / tag ink
|
||||||
│─────────────────────────────────│
|
│─────────────────────────────────│
|
||||||
│ ОБЖАРКА СВЕТЛАЯ │ только если есть в ТЗ
|
│ ОБЖАРКА светлая │ spec из site.ts, если есть
|
||||||
│─────────────────────────────────│
|
│─────────────────────────────────│
|
||||||
│ ДАТА ОБЖАРКИ [ЧТ 22.10] │ поле + Stamp sm
|
│ ДАТА ОБЖАРКИ [ЧТ 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`.
|
- Padding 20px (390) / 24px (1440). Поля этикетки — строки min-height 40px, подпись tag `muted` колонкой 96px, значение tag `ink`.
|
||||||
- Строка третьего поля только из фактов: Эфиопия — «ОБЖАРКА: СВЕТЛАЯ», Бразилия — «ПОД ЭСПРЕССО», у Колумбии строки нет (в ТЗ нет — не выдумывать).
|
- Поле `spec` (site.ts) только из фактов: Эфиопия — «ОБЖАРКА — светлая», Бразилия — «ДЛЯ — эспрессо», дрип — «КАК — заваривается прямо в чашке», у Колумбии поля нет (в ТЗ нет — не выдумывать). Значение — Golos small 500.
|
||||||
- **Ribbon:** абсолютный блок 160×28px, `cherry`, текст tag `paper` «ОБЖАРЕНО В ЧТ», `rotate(45deg)`, центр в точке (right: −40px, top: 24px), срезан `overflow: hidden` карточки. `aria-hidden`; тот же смысл есть в поле «дата обжарки».
|
- Строка «ДАТА ОБЖАРКИ»: 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 — как обычная этикетка, но ниже (без поля нот). Лента та же.
|
- **Дрип-пакеты:** вариант `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`.
|
- **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.
|
- **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` (уже в коде).
|
- **`[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:** не трогать.
|
- **Honeypot:** не трогать.
|
||||||
|
|
||||||
### Уведомления (`[data-lead-notice]`, по `data-notice` на обёртке)
|
### Уведомления (`[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`, «Бразилия Серрадо, 250 г»
|
- Бразилия: `one_time`, «Бразилия Серрадо, 250 г»
|
||||||
- Дрип: `one_time`, «Дрип-пакеты ассорти, 10 шт»
|
- Дрип: `one_time`, «Дрип-пакеты ассорти, 10 шт»
|
||||||
- Фото нет. SVG: лента, перфорация, стрелка, штампы `sm` (статичные, повороты −4°, 3°, −2°, 5° — разные, чтобы не было копипасты).
|
- Фото нет. SVG: перфорация, стрелка, штампы (статичные, повороты и сдвиги — см. Label).
|
||||||
|
- Дрип на ≥1024: три зоны (название / поля / цена и кнопка) по центру по вертикали, между зонами вертикальная линейка 1px `ink`, верхних линеек у первой строки полей и у цены нет.
|
||||||
|
|
||||||
### 4. Подписка `#subscription`
|
### 4. Подписка `#subscription`
|
||||||
- **Факты:** 2 пачки по 250 г раз в месяц, доставка бесплатно, 54 BYN/мес.
|
- **Факты:** 2 пачки по 250 г раз в месяц, доставка бесплатно, 54 BYN/мес.
|
||||||
@@ -455,14 +455,14 @@ const martian = Martian_Mono({ subsets: ['cyrillic', 'latin'], variable: '--font
|
|||||||
|
|
||||||
### 5. Кто мы `#about`
|
### 5. Кто мы `#about`
|
||||||
- **Факты:** с 2019 года, основатели Аня и Дмитрий, бывшие бариста, партии по 5 кг. Ничего больше (ни цитат, ни наград).
|
- **Факты:** с 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, выровнен по низу фото.
|
- **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: «Свежеобжаренное зерно остывает в охладителе ростера». Подпись/текст не утверждает, что это цех «Кварты».
|
- **Фото:** _7CVm353m7A (Scott Soltys-Curry, охладитель ростера). `sizes="(min-width: 1024px) 610px, 100vw"`, `object-position: center 40%`, lazy. Alt: «Свежеобжаренное зерно остывает в охладителе ростера». Подпись/текст не утверждает, что это цех «Кварты».
|
||||||
- CTA нет.
|
- CTA нет.
|
||||||
|
|
||||||
### 6. Доставка и самовывоз `#delivery` (тёмная, класс `on-dark`)
|
### 6. Доставка и самовывоз `#delivery` (тёмная, класс `on-dark`)
|
||||||
- **Фон:** `roast`, текст `cream`. Факты: доставка по Минску в пятницу, от 50 BYN бесплатно, дешевле — стоимость уточнит менеджер; самовывоз ул. Короля, 9; пн–пт 8:00–20:00, сб–вс 10:00–18:00.
|
- **Фон:** `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 (`<dl>`: «ПН–ПТ 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 (`<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 снизу.
|
- **1440:** фото кол. 1–5 на всю высоту секции (bleed влево до края экрана), справа кол. 7–12: H2, два блока рядом (по 3 колонки), CTA снизу.
|
||||||
- **Фото:** obV_LM0KjxY (Tina Guina, мешок на тёмном). `sizes="(min-width: 1024px) 40vw, 100vw"`, `object-position: center`, lazy. Alt: «Открытый мешок с кофейным зерном».
|
- **Фото:** 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`.
|
- **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`
|
### 7. Форма `#lead`
|
||||||
- Существующий `LeadForm` в блоке paper (см. «Поля формы»). `scroll-mt-4` уже есть.
|
- Существующий `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: блок формы.
|
- **1440:** кол. 1–5: H2, lead, телефон Martian 22px, часы работы tag; кол. 6–12: блок формы.
|
||||||
- Комментарий, пришедший из CTA, виден в textarea — посетитель его видит и может стереть.
|
- Комментарий, пришедший из CTA, виден в textarea — посетитель его видит и может стереть.
|
||||||
|
|
||||||
|
|||||||
@@ -286,12 +286,14 @@
|
|||||||
background-color 120ms ease-out,
|
background-color 120ms ease-out,
|
||||||
color 120ms ease-out;
|
color 120ms ease-out;
|
||||||
}
|
}
|
||||||
|
/* кружок радиокнопки: выбор одиночный */
|
||||||
.talon::before {
|
.talon::before {
|
||||||
content: '';
|
content: '';
|
||||||
width: 10px;
|
width: 12px;
|
||||||
height: 10px;
|
height: 12px;
|
||||||
flex: none;
|
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] {
|
.talon[data-checked] {
|
||||||
background: var(--color-ink);
|
background: var(--color-ink);
|
||||||
@@ -299,8 +301,8 @@
|
|||||||
box-shadow: inset 0 0 0 1.5px var(--color-ink);
|
box-shadow: inset 0 0 0 1.5px var(--color-ink);
|
||||||
}
|
}
|
||||||
.talon[data-checked]::before {
|
.talon[data-checked]::before {
|
||||||
background: var(--color-cherry);
|
background: radial-gradient(circle, var(--color-cherry) 0 3px, transparent 3.5px);
|
||||||
box-shadow: none;
|
box-shadow: inset 0 0 0 1.5px var(--color-cream);
|
||||||
}
|
}
|
||||||
.talon:has(:focus-visible) {
|
.talon:has(:focus-visible) {
|
||||||
outline: 2px solid var(--color-ink);
|
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 {
|
.perforation {
|
||||||
background-image: linear-gradient(to right, var(--color-ink) 0 6px, transparent 6px 10px);
|
background-image: linear-gradient(to right, var(--color-ink) 0 6px, transparent 6px 10px);
|
||||||
background-size: 10px 1px;
|
background-size: 10px 1px;
|
||||||
@@ -341,7 +330,11 @@
|
|||||||
.stamp {
|
.stamp {
|
||||||
display: inline-block;
|
display: inline-block;
|
||||||
transform: rotate(var(--stamp-rotate, -6deg));
|
transform: rotate(var(--stamp-rotate, -6deg));
|
||||||
opacity: 0.92;
|
}
|
||||||
|
/* бумажная подложка под штамп на фото: непрозрачная, поворачивается вместе со штампом */
|
||||||
|
.stamp[data-backing] {
|
||||||
|
background: var(--color-paper);
|
||||||
|
padding: 10px;
|
||||||
}
|
}
|
||||||
.stamp-text {
|
.stamp-text {
|
||||||
font-family: var(--font-mono);
|
font-family: var(--font-mono);
|
||||||
@@ -464,6 +457,10 @@
|
|||||||
.week-note {
|
.week-note {
|
||||||
padding: 16px 16px 0;
|
padding: 16px 16px 0;
|
||||||
}
|
}
|
||||||
|
.week-note .text-small,
|
||||||
|
.week-bracket-note .text-small {
|
||||||
|
font-size: 1rem;
|
||||||
|
}
|
||||||
.week-bracket {
|
.week-bracket {
|
||||||
grid-column: 5 / 8;
|
grid-column: 5 / 8;
|
||||||
grid-row: 2;
|
grid-row: 2;
|
||||||
@@ -491,7 +488,7 @@
|
|||||||
transform: rotate(calc(var(--stamp-rotate, -6deg) - 3deg)) scale(1.18);
|
transform: rotate(calc(var(--stamp-rotate, -6deg) - 3deg)) scale(1.18);
|
||||||
}
|
}
|
||||||
to {
|
to {
|
||||||
opacity: 0.92;
|
opacity: 1;
|
||||||
transform: rotate(var(--stamp-rotate, -6deg)) scale(1);
|
transform: rotate(var(--stamp-rotate, -6deg)) scale(1);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -512,7 +509,7 @@
|
|||||||
|
|
||||||
@media (prefers-reduced-motion: no-preference) {
|
@media (prefers-reduced-motion: no-preference) {
|
||||||
[data-motion] .stamp[data-stamp='load'] {
|
[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'] {
|
.stamp[data-stamp='event'] {
|
||||||
animation: stamp-in 380ms var(--ease-out-expo) 80ms both;
|
animation: stamp-in 380ms var(--ease-out-expo) 80ms both;
|
||||||
|
|||||||
@@ -1,21 +1,32 @@
|
|||||||
// Этикетка пачки: крафт, поля через линейки, лента «обжарено в чт», штамп даты обжарки.
|
// Этикетка пачки: крафт, поля через линейки, штамп даты обжарки. wide — горизонтальная (дрип-пакеты на ≥1024).
|
||||||
import { site } from '@/content/site';
|
import { site } from '@/content/site';
|
||||||
|
import { keep, typo } from '@/lib/typo';
|
||||||
import { CtaButton } from './CtaButton';
|
import { CtaButton } from './CtaButton';
|
||||||
import { PriceTag } from './PriceTag';
|
import { PriceTag } from './PriceTag';
|
||||||
import { Stamp } from './Stamp';
|
import { Stamp } from './Stamp';
|
||||||
|
|
||||||
type Item = (typeof site.catalog.items)[number];
|
type Item = (typeof site.catalog.items)[number];
|
||||||
|
|
||||||
const ROTATIONS: Record<string, number> = { ethiopia: -4, colombia: 3, brazil: -2, drip: 5 };
|
// разные повороты и сдвиги, чтобы штампы не выглядели копией
|
||||||
|
const STAMP: Record<string, { rotate: number; x: number }> = {
|
||||||
|
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] {
|
function splitName(name: string): [string, string] {
|
||||||
const i = name.indexOf(' ');
|
const i = name.indexOf(' ');
|
||||||
return i === -1 ? [name, ''] : [name.slice(0, i), name.slice(i + 1)];
|
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 (
|
return (
|
||||||
<div className="flex min-h-10 items-center gap-3 border-t border-ink py-2">
|
<div
|
||||||
|
className={`flex items-center gap-3 border-t border-ink ${tall ? 'min-h-[72px] py-3' : '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="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">{children}</span>
|
||||||
</div>
|
</div>
|
||||||
@@ -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 }) {
|
export function Label({ item, roastDate, wide = false }: { item: Item; roastDate: string; wide?: boolean }) {
|
||||||
const [title, region] = splitName(item.name);
|
const [title, region] = splitName(item.name);
|
||||||
const { label } = site.catalog;
|
const { label } = site.catalog;
|
||||||
const extra = item.roast ?? item.brew;
|
const stamp = STAMP[item.id] ?? { rotate: -3, x: 0 };
|
||||||
|
|
||||||
const head = (
|
const head = (
|
||||||
<div>
|
<div className={wide ? 'mb-6 lg:mb-0 lg:pr-8' : 'mb-6'}>
|
||||||
<p className="tag text-muted">{label.brand}</p>
|
<p className="tag text-muted">{label.brand}</p>
|
||||||
<h3 id={`label-${item.id}`} className="mt-5 text-title font-extrabold">
|
<h3 id={`label-${item.id}`} className="mt-5 text-title font-extrabold">
|
||||||
{title}
|
{title}
|
||||||
{region && <span className="mt-1 block text-small font-medium tracking-normal text-muted">{region}</span>}
|
{region && <span className="mt-1 block text-small font-medium tracking-normal text-muted">{region}</span>}
|
||||||
</h3>
|
</h3>
|
||||||
{extra && <p className="mt-3 text-small font-medium text-ink">{extra}</p>}
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
||||||
const fields = (
|
const fields = (
|
||||||
<div>
|
<div className={wide ? 'lg:px-8' : 'mt-auto'}>
|
||||||
{item.notes.length > 0 && (
|
{item.notes.length > 0 && (
|
||||||
<Row label={label.notes}>
|
<Row label={label.notes} wide={wide}>
|
||||||
<span className="tag text-ink">{item.notes.join(' • ')}</span>
|
<span className="tag text-ink">{item.notes.map(keep).join(' • ')}</span>
|
||||||
</Row>
|
</Row>
|
||||||
)}
|
)}
|
||||||
<Row label={site.roastDate.label}>
|
{item.spec && (
|
||||||
<Stamp lines={[{ text: `ЧТ ${roastDate}`, size: 'lg' }]} width={136} rotate={ROTATIONS[item.id] ?? -3} />
|
<Row label={item.spec.label} wide={wide}>
|
||||||
|
<span className="text-small font-medium text-ink">{typo(item.spec.value)}</span>
|
||||||
|
</Row>
|
||||||
|
)}
|
||||||
|
<Row label={site.roastDate.label} tall wide={wide}>
|
||||||
|
<Stamp
|
||||||
|
lines={[{ text: `ЧТ ${roastDate}`, size: 'lg' }]}
|
||||||
|
width={136}
|
||||||
|
rotate={stamp.rotate}
|
||||||
|
className="block"
|
||||||
|
offsetX={stamp.x}
|
||||||
|
/>
|
||||||
<span className="sr-only">
|
<span className="sr-only">
|
||||||
{site.roastDate.srPrefix}: {roastDate}
|
{site.roastDate.srPrefix}: {roastDate}
|
||||||
</span>
|
</span>
|
||||||
@@ -55,8 +76,8 @@ export function Label({ item, roastDate, wide = false }: { item: Item; roastDate
|
|||||||
);
|
);
|
||||||
|
|
||||||
const buy = (
|
const buy = (
|
||||||
<div className="flex flex-col gap-4">
|
<div className={`flex flex-col gap-4 ${wide ? 'lg:pl-8' : ''}`}>
|
||||||
<PriceTag size={item.size} price={item.price} />
|
<PriceTag size={item.size} price={item.price} bareOnWide={wide} />
|
||||||
<CtaButton
|
<CtaButton
|
||||||
interest={item.cta.interest}
|
interest={item.cta.interest}
|
||||||
comment={item.cta.comment}
|
comment={item.cta.comment}
|
||||||
@@ -71,18 +92,17 @@ export function Label({ item, roastDate, wide = false }: { item: Item; roastDate
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<article
|
<article
|
||||||
className={`relative overflow-hidden border border-ink bg-kraft p-5 lg:p-6 ${
|
className={`relative border border-ink bg-kraft p-5 lg:p-6 ${
|
||||||
wide ? 'flex flex-col gap-6 lg:grid lg:grid-cols-[1fr_1.4fr_1fr] lg:items-end lg:gap-10 lg:pr-24' : 'flex flex-col gap-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'
|
||||||
}`}
|
}`}
|
||||||
aria-labelledby={`label-${item.id}`}
|
aria-labelledby={`label-${item.id}`}
|
||||||
>
|
>
|
||||||
{item.id === 'drip' && <span className="perforation absolute inset-x-0 top-2" aria-hidden="true" />}
|
|
||||||
<span className="ribbon tag" aria-hidden="true">
|
|
||||||
{label.ribbon}
|
|
||||||
</span>
|
|
||||||
{head}
|
{head}
|
||||||
<div className={wide ? '' : 'mt-auto'}>{fields}</div>
|
{fields}
|
||||||
{buy}
|
{buy}
|
||||||
|
{item.id === 'drip' && <span className="perforation absolute inset-x-0 top-2" aria-hidden="true" />}
|
||||||
</article>
|
</article>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,14 +1,27 @@
|
|||||||
// Вес слева, цена справа моноширинным; «BYN» мельче.
|
// Вес слева, цена справа моноширинным; «BYN» мельче, единица («в месяц») — Golos приглушённым.
|
||||||
export function PriceTag({ size, price, suffix, large = false }: { size?: string; price: number; suffix?: string; large?: boolean }) {
|
export function PriceTag({
|
||||||
|
size,
|
||||||
|
price,
|
||||||
|
unit,
|
||||||
|
large = false,
|
||||||
|
bareOnWide = false,
|
||||||
|
}: {
|
||||||
|
size?: string;
|
||||||
|
price: number;
|
||||||
|
unit?: string;
|
||||||
|
large?: boolean;
|
||||||
|
/** без верхней линейки на ≥1024 — у горизонтальной этикетки зоны делят вертикальные линейки */
|
||||||
|
bareOnWide?: boolean;
|
||||||
|
}) {
|
||||||
return (
|
return (
|
||||||
<p className="flex items-baseline justify-between gap-4 border-t border-ink pt-3">
|
<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">{size}</span> : <span />}
|
{size ? <span className="mono text-base font-medium whitespace-nowrap">{size}</span> : <span />}
|
||||||
<span className="mono whitespace-nowrap">
|
<span className="whitespace-nowrap">
|
||||||
<span className={`${large ? 'text-[clamp(2.25rem,1.6rem+2vw,3rem)]' : 'text-price'} font-semibold tracking-[-0.02em]`}>
|
<span className={`mono ${large ? 'text-[clamp(2.25rem,1.6rem+2vw,3rem)]' : 'text-price'} font-semibold tracking-[-0.02em]`}>
|
||||||
{price}
|
{price}
|
||||||
</span>
|
</span>
|
||||||
<span className={`${large ? 'text-xl' : 'text-sm'} ml-1.5 font-semibold`}>BYN</span>
|
<span className={`mono ${large ? 'text-xl' : 'text-sm'} ml-1.5 font-semibold`}>BYN</span>
|
||||||
{suffix && <span className="ml-2 text-base font-medium text-muted">{suffix}</span>}
|
{unit && <span className="ml-2 font-sans text-base font-medium text-muted">{unit}</span>}
|
||||||
</span>
|
</span>
|
||||||
</p>
|
</p>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -15,6 +15,8 @@ export type StampProps = {
|
|||||||
animate?: 'load' | 'event';
|
animate?: 'load' | 'event';
|
||||||
/** бумажная подложка — когда штамп лежит на фото */
|
/** бумажная подложка — когда штамп лежит на фото */
|
||||||
backing?: boolean;
|
backing?: boolean;
|
||||||
|
/** сдвиг по X, px — «мимо приводки» у соседних штампов */
|
||||||
|
offsetX?: number;
|
||||||
className?: string;
|
className?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -23,21 +25,23 @@ const LINE_H = { sm: 26, lg: 46 } as const;
|
|||||||
const FONT = { sm: 15, lg: 34 } as const;
|
const FONT = { sm: 15, lg: 34 } as const;
|
||||||
const PAD = 16;
|
const PAD = 16;
|
||||||
|
|
||||||
export function Stamp({ lines, width = 168, rotate = -6, tone = 'cherry', animate, backing = false, className }: StampProps) {
|
export function Stamp({ lines, width = 168, rotate = -6, tone = 'cherry', animate, backing = false, offsetX = 0, className }: StampProps) {
|
||||||
const heights = lines.map((l) => LINE_H[l.size ?? 'sm']);
|
const heights = lines.map((l) => LINE_H[l.size ?? 'sm']);
|
||||||
const H = PAD * 2 + heights.reduce((a, b) => a + b, 0);
|
const H = PAD * 2 + heights.reduce((a, b) => a + b, 0);
|
||||||
const tops = heights.map((_, i) => PAD + heights.slice(0, i).reduce((a, b) => a + b, 0));
|
const tops = heights.map((_, i) => PAD + heights.slice(0, i).reduce((a, b) => a + b, 0));
|
||||||
const color = `var(--color-${tone})`;
|
const color = `var(--color-${tone})`;
|
||||||
const style = { '--stamp-rotate': `${rotate}deg`, width } as CSSProperties;
|
const style = { '--stamp-rotate': `${rotate}deg`, width, marginLeft: offsetX || undefined } as CSSProperties;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<span className={`stamp ${className ?? ''}`} style={style} data-stamp={animate} aria-hidden="true">
|
<span className={`stamp ${className ?? ''}`} style={style} data-stamp={animate} data-backing={backing ? '' : undefined} aria-hidden="true">
|
||||||
<svg viewBox={`0 0 ${W} ${H}`} width="100%" focusable="false" style={{ display: 'block', overflow: 'visible' }}>
|
<svg viewBox={`0 0 ${W} ${H}`} width="100%" focusable="false" style={{ display: 'block', overflow: 'visible' }}>
|
||||||
{backing && <rect x={-6} y={-6} width={W + 12} height={H + 12} fill="var(--color-paper)" />}
|
<g fill={color} stroke={color} opacity={0.92}>
|
||||||
<g filter="url(#stamp-ink)" fill={color} stroke={color}>
|
<g filter="url(#stamp-ink)">
|
||||||
<rect x={1} y={1} width={W - 2} height={H - 2} fill="none" strokeWidth={2.5} />
|
<rect x={1} y={1} width={W - 2} height={H - 2} fill="none" strokeWidth={2.5} />
|
||||||
<rect x={6} y={6} width={W - 12} height={H - 12} fill="none" strokeWidth={1.2} />
|
<rect x={6} y={6} width={W - 12} height={H - 12} fill="none" strokeWidth={1.2} />
|
||||||
<g transform="translate(1.2 -1)" stroke="none">
|
</g>
|
||||||
|
{/* цифры — фильтр мягче: теряют не больше ~5% штриха */}
|
||||||
|
<g transform="translate(1.2 -1)" stroke="none" filter="url(#stamp-ink-text)">
|
||||||
{lines.map((line, i) => {
|
{lines.map((line, i) => {
|
||||||
const size = line.size ?? 'sm';
|
const size = line.size ?? 'sm';
|
||||||
const h = heights[i] ?? 0;
|
const h = heights[i] ?? 0;
|
||||||
@@ -78,6 +82,13 @@ export function StampInkFilter() {
|
|||||||
<feColorMatrix in="blotch" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 -3.2 0 0 0 2.35" result="mask" />
|
<feColorMatrix in="blotch" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 -3.2 0 0 0 2.35" result="mask" />
|
||||||
<feComposite in="shaky" in2="mask" operator="in" />
|
<feComposite in="shaky" in2="mask" operator="in" />
|
||||||
</filter>
|
</filter>
|
||||||
|
<filter id="stamp-ink-text" x="-4%" y="-4%" width="108%" height="108%" colorInterpolationFilters="sRGB">
|
||||||
|
<feTurbulence type="fractalNoise" baseFrequency="0.85" numOctaves={2} seed={7} result="grain" />
|
||||||
|
<feDisplacementMap in="SourceGraphic" in2="grain" scale={1.2} xChannelSelector="R" yChannelSelector="G" result="shaky" />
|
||||||
|
<feTurbulence type="fractalNoise" baseFrequency="0.07" numOctaves={3} seed={11} result="blotch" />
|
||||||
|
<feColorMatrix in="blotch" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 -4 0 0 0 3.6" result="mask" />
|
||||||
|
<feComposite in="shaky" in2="mask" operator="in" />
|
||||||
|
</filter>
|
||||||
</defs>
|
</defs>
|
||||||
</svg>
|
</svg>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
// 390 — столбиком, ≥1024 — строкой. Анимация — в CSS по [data-inview] (WeekStripObserver).
|
// 390 — столбиком, ≥1024 — строкой. Анимация — в CSS по [data-inview] (WeekStripObserver).
|
||||||
import { site } from '@/content/site';
|
import { site } from '@/content/site';
|
||||||
import type { RoastWeek } from '@/lib/roastWeek';
|
import type { RoastWeek } from '@/lib/roastWeek';
|
||||||
|
import { typo } from '@/lib/typo';
|
||||||
|
|
||||||
const [roastNote, deliveryNote, cupNote] = site.freshness.days;
|
const [roastNote, deliveryNote, cupNote] = site.freshness.days;
|
||||||
const NOTES: Record<number, (typeof site.freshness.days)[number] | undefined> = { 3: roastNote, 4: deliveryNote };
|
const NOTES: Record<number, (typeof site.freshness.days)[number] | undefined> = { 3: roastNote, 4: deliveryNote };
|
||||||
@@ -26,7 +27,7 @@ export function WeekStrip({ week }: { week: RoastWeek }) {
|
|||||||
{note && (
|
{note && (
|
||||||
<div className="week-note">
|
<div className="week-note">
|
||||||
<p className="tag text-cherry">{note.label}</p>
|
<p className="tag text-cherry">{note.label}</p>
|
||||||
<p className="text-small text-ink">{note.caption}</p>
|
<p className="text-small text-ink">{typo(note.caption)}</p>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</li>
|
</li>
|
||||||
@@ -39,7 +40,7 @@ export function WeekStrip({ week }: { week: RoastWeek }) {
|
|||||||
<p className="tag text-cherry">
|
<p className="tag text-cherry">
|
||||||
{cupNote.day} · {cupNote.label}
|
{cupNote.day} · {cupNote.label}
|
||||||
</p>
|
</p>
|
||||||
<p className="text-small text-ink">{cupNote.caption}</p>
|
<p className="text-small text-ink">{typo(cupNote.caption)}</p>
|
||||||
</div>
|
</div>
|
||||||
</li>
|
</li>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import Image from 'next/image';
|
import Image from 'next/image';
|
||||||
import { site } from '@/content/site';
|
import { site } from '@/content/site';
|
||||||
|
import { typo } from '@/lib/typo';
|
||||||
import cooler from '../../../public/photos/cooler.jpg';
|
import cooler from '../../../public/photos/cooler.jpg';
|
||||||
|
|
||||||
export function About() {
|
export function About() {
|
||||||
@@ -23,17 +24,10 @@ export function About() {
|
|||||||
</h2>
|
</h2>
|
||||||
<div className="mt-5 flex max-w-[62ch] flex-col gap-4">
|
<div className="mt-5 flex max-w-[62ch] flex-col gap-4">
|
||||||
{about.text.map((p) => (
|
{about.text.map((p) => (
|
||||||
<p key={p}>{p}</p>
|
<p key={p}>{typo(p)}</p>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
<dl className="mt-10 grid grid-cols-3 border-y border-ink">
|
<p className="tag mt-10 border-y border-ink py-3 text-muted">{typo(about.factsLine)}</p>
|
||||||
{about.facts.map((fact) => (
|
|
||||||
<div key={fact.label} className="flex flex-col-reverse justify-end gap-2 border-l border-ink py-4 pl-3 first:border-l-0 first:pl-0">
|
|
||||||
<dt className="tag text-muted">{fact.label}</dt>
|
|
||||||
<dd className="mono text-[22px] leading-none font-semibold">{fact.value}</dd>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</dl>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { site } from '@/content/site';
|
import { site } from '@/content/site';
|
||||||
|
import { typo } from '@/lib/typo';
|
||||||
import { Label } from '../Label';
|
import { Label } from '../Label';
|
||||||
|
|
||||||
export function Catalog({ roastDate }: { roastDate: string }) {
|
export function Catalog({ roastDate }: { roastDate: string }) {
|
||||||
@@ -11,7 +12,7 @@ export function Catalog({ roastDate }: { roastDate: string }) {
|
|||||||
<h2 id="coffee-title" className="text-headline font-extrabold">
|
<h2 id="coffee-title" className="text-headline font-extrabold">
|
||||||
{catalog.title}
|
{catalog.title}
|
||||||
</h2>
|
</h2>
|
||||||
<p className="mt-4 max-w-[46ch] text-lead">{catalog.subtitle}</p>
|
<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-6">
|
||||||
{packs.map((item) => (
|
{packs.map((item) => (
|
||||||
<Label key={item.id} item={item} roastDate={roastDate} />
|
<Label key={item.id} item={item} roastDate={roastDate} />
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import Image from 'next/image';
|
import Image from 'next/image';
|
||||||
import { site } from '@/content/site';
|
import { site } from '@/content/site';
|
||||||
|
import { typo } from '@/lib/typo';
|
||||||
import { CtaButton } from '../CtaButton';
|
import { CtaButton } from '../CtaButton';
|
||||||
import sack from '../../../public/photos/sack.jpg';
|
import sack from '../../../public/photos/sack.jpg';
|
||||||
|
|
||||||
@@ -7,20 +8,21 @@ export function Delivery() {
|
|||||||
const { delivery } = site;
|
const { delivery } = site;
|
||||||
const { pickup } = delivery;
|
const { pickup } = delivery;
|
||||||
return (
|
return (
|
||||||
<section id="delivery" aria-labelledby="delivery-title" className="on-dark bg-roast text-cream">
|
<section id="delivery" aria-labelledby="delivery-title" className="on-dark relative bg-roast text-cream lg:min-h-[640px]">
|
||||||
<div className="grid lg:grid-cols-12">
|
{/* на мобиле фото мешка скрыто: рядом уже охладитель из «Кто обжаривает» */}
|
||||||
<div className="relative aspect-[390/230] lg:col-span-5 lg:aspect-auto lg:min-h-[640px]">
|
<div className="relative hidden aspect-[768/300] md:block lg:absolute lg:inset-y-0 lg:left-0 lg:aspect-auto lg:w-[42%]">
|
||||||
<Image
|
<Image
|
||||||
src={sack}
|
src={sack}
|
||||||
alt={site.images.sack.alt}
|
alt={site.images.sack.alt}
|
||||||
fill
|
fill
|
||||||
sizes="(min-width: 1024px) 42vw, 100vw"
|
sizes="(min-width: 1024px) 42vw, (min-width: 768px) 100vw, 1px"
|
||||||
className="object-cover object-[center_45%]"
|
className="object-cover object-[center_45%]"
|
||||||
placeholder="blur"
|
placeholder="blur"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="page-x section-y lg:col-span-7 lg:pr-12 lg:pl-16">
|
<div className="page-x section-y">
|
||||||
<div className="max-w-[720px]">
|
<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">
|
<h2 id="delivery-title" className="text-headline font-extrabold">
|
||||||
{delivery.title}
|
{delivery.title}
|
||||||
</h2>
|
</h2>
|
||||||
@@ -30,7 +32,7 @@ export function Delivery() {
|
|||||||
<ul className="mt-5">
|
<ul className="mt-5">
|
||||||
{delivery.delivery.lines.map((line) => (
|
{delivery.delivery.lines.map((line) => (
|
||||||
<li key={line} className="border-t border-muted-on-dark py-3 first:border-t-0 first:pt-0">
|
<li key={line} className="border-t border-muted-on-dark py-3 first:border-t-0 first:pt-0">
|
||||||
{line}
|
{typo(line)}
|
||||||
</li>
|
</li>
|
||||||
))}
|
))}
|
||||||
</ul>
|
</ul>
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { site } from '@/content/site';
|
import { site } from '@/content/site';
|
||||||
|
import { typo } from '@/lib/typo';
|
||||||
import { Logo } from '../Logo';
|
import { Logo } from '../Logo';
|
||||||
|
|
||||||
export function Footer() {
|
export function Footer() {
|
||||||
@@ -8,7 +9,7 @@ export function Footer() {
|
|||||||
<div className="container-page grid gap-8 md:grid-cols-2 lg:grid-cols-4 lg:gap-6">
|
<div className="container-page grid gap-8 md:grid-cols-2 lg:grid-cols-4 lg:gap-6">
|
||||||
<div>
|
<div>
|
||||||
<Logo name={footer.name} onDark />
|
<Logo name={footer.name} onDark />
|
||||||
<p className="mt-4 text-small text-muted-on-dark">{footer.tagline}</p>
|
<p className="mt-4 text-small text-muted-on-dark">{typo(footer.tagline)}</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="text-small">
|
<div className="text-small">
|
||||||
<p>{footer.address}</p>
|
<p>{footer.address}</p>
|
||||||
@@ -18,11 +19,11 @@ export function Footer() {
|
|||||||
<a href={footer.phone.href} className="mono inline-flex min-h-11 items-center text-ui font-medium">
|
<a href={footer.phone.href} className="mono inline-flex min-h-11 items-center text-ui font-medium">
|
||||||
{footer.phone.display}
|
{footer.phone.display}
|
||||||
</a>
|
</a>
|
||||||
<p className="text-muted-on-dark">{footer.phoneNote}</p>
|
<p className="text-muted-on-dark">{typo(footer.phoneNote)}</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="text-small text-muted-on-dark">
|
<div className="text-small text-muted-on-dark">
|
||||||
<p>{footer.photoCredit}</p>
|
<p>{typo(footer.photoCredit)}</p>
|
||||||
<p className="mt-1">{footer.disclaimer}</p>
|
<p className="mt-1">{typo(footer.disclaimer)}</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</footer>
|
</footer>
|
||||||
|
|||||||
@@ -1,16 +1,17 @@
|
|||||||
import { site } from '@/content/site';
|
import { site } from '@/content/site';
|
||||||
|
import { typo } from '@/lib/typo';
|
||||||
import type { RoastWeek } from '@/lib/roastWeek';
|
import type { RoastWeek } from '@/lib/roastWeek';
|
||||||
import { WeekStrip } from '../WeekStrip';
|
import { WeekStrip } from '../WeekStrip';
|
||||||
|
|
||||||
export function Freshness({ week }: { week: RoastWeek }) {
|
export function Freshness({ week }: { week: RoastWeek }) {
|
||||||
const { freshness } = site;
|
const { freshness } = site;
|
||||||
return (
|
return (
|
||||||
<section id="fresh" aria-labelledby="fresh-title" className="page-x section-y">
|
<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">
|
<div className="container-page border-t-2 border-ink pt-10 lg:pt-14">
|
||||||
<h2 id="fresh-title" className="text-headline font-extrabold">
|
<h2 id="fresh-title" className="text-headline font-extrabold">
|
||||||
{freshness.title}
|
{freshness.title}
|
||||||
</h2>
|
</h2>
|
||||||
<p className="mt-4 max-w-[46ch] text-lead">{freshness.why}</p>
|
<p className="mt-4 max-w-[46ch] text-lead">{typo(freshness.why)}</p>
|
||||||
<div className="mt-10 lg:mt-16">
|
<div className="mt-10 lg:mt-16">
|
||||||
<WeekStrip week={week} />
|
<WeekStrip week={week} />
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import Image from 'next/image';
|
import Image from 'next/image';
|
||||||
import { site } from '@/content/site';
|
import { site } from '@/content/site';
|
||||||
|
import { typo } from '@/lib/typo';
|
||||||
import { CtaButton } from '../CtaButton';
|
import { CtaButton } from '../CtaButton';
|
||||||
import { Stamp } from '../Stamp';
|
import { Stamp } from '../Stamp';
|
||||||
import beans from '../../../public/photos/beans.jpg';
|
import beans from '../../../public/photos/beans.jpg';
|
||||||
@@ -9,11 +10,11 @@ export function Hero({ roastDate }: { roastDate: string }) {
|
|||||||
return (
|
return (
|
||||||
<section id="hero" aria-labelledby="hero-title" className="page-x pt-6 lg:pt-12">
|
<section id="hero" aria-labelledby="hero-title" className="page-x pt-6 lg:pt-12">
|
||||||
<div className="container-page grid gap-y-8 lg:grid-cols-12 lg:gap-x-6">
|
<div className="container-page grid gap-y-8 lg:grid-cols-12 lg:gap-x-6">
|
||||||
<div className="flex flex-col lg:col-span-7 lg:pt-10">
|
<div className="flex flex-col lg:col-span-7">
|
||||||
<h1 id="hero-title" className="max-w-[11ch] text-display font-black">
|
<h1 id="hero-title" className="max-w-[11ch] text-display font-black">
|
||||||
{hero.title}
|
{typo(hero.title)}
|
||||||
</h1>
|
</h1>
|
||||||
<p className="mt-5 max-w-[34ch] text-lead text-ink lg:mt-8">{hero.subtitle}</p>
|
<p className="mt-5 max-w-[34ch] text-lead text-ink lg:mt-8">{typo(hero.subtitle)}</p>
|
||||||
<div className="mt-8 flex flex-col gap-3 sm:flex-row lg:mt-10" data-hero-cta="">
|
<div className="mt-8 flex flex-col gap-3 sm:flex-row lg:mt-10" data-hero-cta="">
|
||||||
<CtaButton interest={hero.ctaPrimary.interest} className="btn btn-primary w-full sm:w-auto">
|
<CtaButton interest={hero.ctaPrimary.interest} className="btn btn-primary w-full sm:w-auto">
|
||||||
{hero.ctaPrimary.label}
|
{hero.ctaPrimary.label}
|
||||||
@@ -23,7 +24,7 @@ export function Hero({ roastDate }: { roastDate: string }) {
|
|||||||
</CtaButton>
|
</CtaButton>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="relative -mx-5 mt-14 lg:col-span-5 lg:mx-0 lg:mt-0">
|
<div className="relative -mx-5 mt-10 lg:col-span-5 lg:mx-0 lg:mt-0">
|
||||||
<div className="relative aspect-[390/300] overflow-hidden lg:aspect-[4/5]">
|
<div className="relative aspect-[390/300] overflow-hidden lg:aspect-[4/5]">
|
||||||
<Image
|
<Image
|
||||||
src={beans}
|
src={beans}
|
||||||
@@ -36,7 +37,7 @@ export function Hero({ roastDate }: { roastDate: string }) {
|
|||||||
placeholder="blur"
|
placeholder="blur"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="absolute -top-14 left-5 w-[184px] lg:top-auto lg:-bottom-10 lg:-left-16 lg:w-[232px]">
|
<div className="absolute -top-10 left-5 w-[184px] lg:top-auto lg:-bottom-10 lg:-left-16 lg:w-[232px]">
|
||||||
<Stamp lines={[{ text: hero.stamp }, { text: roastDate, size: 'lg' }]} width="100%" rotate={-6} animate="load" backing />
|
<Stamp lines={[{ text: hero.stamp }, { text: roastDate, size: 'lg' }]} width="100%" rotate={-6} animate="load" backing />
|
||||||
<span className="sr-only">
|
<span className="sr-only">
|
||||||
{site.roastDate.srPrefix}: {roastDate}
|
{site.roastDate.srPrefix}: {roastDate}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { CONTACT_PHONE } from '@/content/form';
|
import { CONTACT_PHONE } from '@/content/form';
|
||||||
import { site } from '@/content/site';
|
import { site } from '@/content/site';
|
||||||
|
import { typo } from '@/lib/typo';
|
||||||
import { LEAD_SECTION_ID } from '@/lib/leadPrefill';
|
import { LEAD_SECTION_ID } from '@/lib/leadPrefill';
|
||||||
import { LeadForm } from '../LeadForm';
|
import { LeadForm } from '../LeadForm';
|
||||||
|
|
||||||
@@ -12,13 +13,11 @@ export function LeadSection() {
|
|||||||
<h2 id="lead-title" className="text-headline font-extrabold">
|
<h2 id="lead-title" className="text-headline font-extrabold">
|
||||||
{lead.title}
|
{lead.title}
|
||||||
</h2>
|
</h2>
|
||||||
<p className="mt-4 max-w-[36ch] text-lead">{lead.subtitle}</p>
|
<p className="mt-4 max-w-[36ch] text-lead">{typo(lead.subtitle)}</p>
|
||||||
<p className="mt-8">
|
<p className="mt-8 text-small text-muted">{lead.call}</p>
|
||||||
{lead.call}{' '}
|
<a href={CONTACT_PHONE.href} className="mono mt-1 inline-flex min-h-11 items-center text-[22px] font-semibold whitespace-nowrap">
|
||||||
<a href={CONTACT_PHONE.href} className="mono inline-flex min-h-11 items-center text-[18px] font-semibold whitespace-nowrap lg:text-[22px]">
|
|
||||||
{CONTACT_PHONE.display}
|
{CONTACT_PHONE.display}
|
||||||
</a>
|
</a>
|
||||||
</p>
|
|
||||||
<dl className="mt-4 hidden max-w-[320px] lg:block">
|
<dl className="mt-4 hidden max-w-[320px] lg:block">
|
||||||
{delivery.pickup.hours.map((row) => (
|
{delivery.pickup.hours.map((row) => (
|
||||||
<div key={row.days} className="flex items-baseline justify-between gap-4 border-t border-ink py-2">
|
<div key={row.days} className="flex items-baseline justify-between gap-4 border-t border-ink py-2">
|
||||||
|
|||||||
@@ -1,11 +1,11 @@
|
|||||||
import { site } from '@/content/site';
|
import { site } from '@/content/site';
|
||||||
|
import { typo } from '@/lib/typo';
|
||||||
import { CtaButton } from '../CtaButton';
|
import { CtaButton } from '../CtaButton';
|
||||||
import { LogoMark } from '../Logo';
|
import { LogoMark } from '../Logo';
|
||||||
import { PriceTag } from '../PriceTag';
|
import { PriceTag } from '../PriceTag';
|
||||||
|
|
||||||
export function Subscription() {
|
export function Subscription() {
|
||||||
const { subscription: sub } = site;
|
const { subscription: sub } = site;
|
||||||
const suffix = sub.priceDisplay.replace(`${sub.price} BYN`, '').trim();
|
|
||||||
return (
|
return (
|
||||||
<section id="subscription" aria-labelledby="subscription-title" className="page-x section-y">
|
<section id="subscription" aria-labelledby="subscription-title" className="page-x section-y">
|
||||||
<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="container-page grid gap-10 border-t-2 border-ink pt-10 lg:grid-cols-12 lg:gap-6 lg:pt-14">
|
||||||
@@ -13,7 +13,7 @@ export function Subscription() {
|
|||||||
<h2 id="subscription-title" className="text-headline font-extrabold">
|
<h2 id="subscription-title" className="text-headline font-extrabold">
|
||||||
{sub.title}
|
{sub.title}
|
||||||
</h2>
|
</h2>
|
||||||
<p className="mt-4 max-w-[40ch] text-lead">{sub.text}</p>
|
<p className="mt-4 max-w-[40ch] text-lead">{typo(sub.text)}</p>
|
||||||
</div>
|
</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-10 lg:p-8">
|
||||||
<div className="flex flex-col gap-6">
|
<div className="flex flex-col gap-6">
|
||||||
@@ -22,17 +22,17 @@ export function Subscription() {
|
|||||||
{sub.fields.map((field) => (
|
{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">
|
||||||
<dt className="tag w-[6.25rem] flex-none text-muted lg:w-[7.5rem]">{field.label}</dt>
|
<dt className="tag w-[6.25rem] flex-none text-muted lg:w-[7.5rem]">{field.label}</dt>
|
||||||
<dd className="tag text-ink">{field.value}</dd>
|
<dd className="tag text-ink">{typo(field.value)}</dd>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</dl>
|
</dl>
|
||||||
</div>
|
</div>
|
||||||
<div className="mt-8 flex flex-col justify-end gap-4 lg:mt-0">
|
<div className="mt-4 flex flex-col justify-end gap-6 lg:mt-0">
|
||||||
<PriceTag price={sub.price} suffix={suffix} large />
|
<PriceTag price={sub.price} unit={sub.priceUnit} large />
|
||||||
<CtaButton interest={sub.cta.interest} comment={sub.cta.comment} className="btn btn-primary w-full">
|
<CtaButton interest={sub.cta.interest} comment={sub.cta.comment} className="btn btn-primary w-full">
|
||||||
{sub.cta.label}
|
{sub.cta.label}
|
||||||
</CtaButton>
|
</CtaButton>
|
||||||
<p className="text-small text-muted">{sub.note}</p>
|
<p className="-mt-2 text-small text-muted">{typo(sub.note)}</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -53,7 +53,7 @@ export const site = {
|
|||||||
catalog: {
|
catalog: {
|
||||||
title: 'Что обжариваем',
|
title: 'Что обжариваем',
|
||||||
subtitle: 'Три сорта в пачках по 250 г и дрип-пакеты.',
|
subtitle: 'Три сорта в пачках по 250 г и дрип-пакеты.',
|
||||||
label: { brand: 'Кварта · Минск', notes: 'Ноты', ribbon: 'Обжарено в чт' },
|
label: { brand: 'Кварта · Минск', notes: 'Ноты' },
|
||||||
items: [
|
items: [
|
||||||
{
|
{
|
||||||
id: 'ethiopia',
|
id: 'ethiopia',
|
||||||
@@ -61,6 +61,8 @@ export const site = {
|
|||||||
notes: ['цветочный', 'бергамот'],
|
notes: ['цветочный', 'бергамот'],
|
||||||
roast: 'светлая обжарка',
|
roast: 'светлая обжарка',
|
||||||
brew: null,
|
brew: null,
|
||||||
|
// поле этикетки (итерация критика 1, нужна вычитка автора)
|
||||||
|
spec: { label: 'Обжарка', value: 'светлая' },
|
||||||
size: '250 г',
|
size: '250 г',
|
||||||
price: 32,
|
price: 32,
|
||||||
priceDisplay: '32 BYN',
|
priceDisplay: '32 BYN',
|
||||||
@@ -72,6 +74,7 @@ export const site = {
|
|||||||
notes: ['карамель', 'красное яблоко'],
|
notes: ['карамель', 'красное яблоко'],
|
||||||
roast: null,
|
roast: null,
|
||||||
brew: null,
|
brew: null,
|
||||||
|
spec: null,
|
||||||
size: '250 г',
|
size: '250 г',
|
||||||
price: 29,
|
price: 29,
|
||||||
priceDisplay: '29 BYN',
|
priceDisplay: '29 BYN',
|
||||||
@@ -83,6 +86,7 @@ export const site = {
|
|||||||
notes: ['орех', 'шоколад'],
|
notes: ['орех', 'шоколад'],
|
||||||
roast: null,
|
roast: null,
|
||||||
brew: 'под эспрессо',
|
brew: 'под эспрессо',
|
||||||
|
spec: { label: 'Для', value: 'эспрессо' },
|
||||||
size: '250 г',
|
size: '250 г',
|
||||||
price: 26,
|
price: 26,
|
||||||
priceDisplay: '26 BYN',
|
priceDisplay: '26 BYN',
|
||||||
@@ -94,6 +98,7 @@ export const site = {
|
|||||||
notes: [],
|
notes: [],
|
||||||
roast: null,
|
roast: null,
|
||||||
brew: 'заваривается прямо в чашке',
|
brew: 'заваривается прямо в чашке',
|
||||||
|
spec: { label: 'Как', value: 'заваривается прямо в чашке' },
|
||||||
size: '10 шт',
|
size: '10 шт',
|
||||||
price: 18,
|
price: 18,
|
||||||
priceDisplay: '18 BYN',
|
priceDisplay: '18 BYN',
|
||||||
@@ -106,7 +111,8 @@ export const site = {
|
|||||||
title: 'Две пачки каждый месяц',
|
title: 'Две пачки каждый месяц',
|
||||||
text: 'Раз в месяц привозим две пачки по 250 г. Доставка бесплатно.',
|
text: 'Раз в месяц привозим две пачки по 250 г. Доставка бесплатно.',
|
||||||
price: 54,
|
price: 54,
|
||||||
priceDisplay: '54 BYN в месяц',
|
priceDisplay: '54 BYN',
|
||||||
|
priceUnit: 'в месяц',
|
||||||
note: 'Подробности менеджер расскажет по телефону.',
|
note: 'Подробности менеджер расскажет по телефону.',
|
||||||
fields: [
|
fields: [
|
||||||
{ label: 'Состав', value: '2 пачки по 250 г' },
|
{ label: 'Состав', value: '2 пачки по 250 г' },
|
||||||
@@ -122,11 +128,8 @@ export const site = {
|
|||||||
'Нас зовут Аня и Дмитрий. Раньше мы работали бариста, а в 2019 году открыли свою обжарку в Минске.',
|
'Нас зовут Аня и Дмитрий. Раньше мы работали бариста, а в 2019 году открыли свою обжарку в Минске.',
|
||||||
'Обжариваем небольшими партиями по 5 кг. «Кварта» значит «четвёртая»: четверг у нас день обжарки.',
|
'Обжариваем небольшими партиями по 5 кг. «Кварта» значит «четвёртая»: четверг у нас день обжарки.',
|
||||||
],
|
],
|
||||||
facts: [
|
// одна строка вместо ряда цифр (итерация критика 1, нужна вычитка автора)
|
||||||
{ value: '2019', label: 'открыли обжарку' },
|
factsLine: 'С 2019 · Минск · партии по 5 кг',
|
||||||
{ value: '5 кг', label: 'одна партия' },
|
|
||||||
{ value: 'ЧТ', label: 'день обжарки' },
|
|
||||||
],
|
|
||||||
},
|
},
|
||||||
|
|
||||||
delivery: {
|
delivery: {
|
||||||
|
|||||||
@@ -0,0 +1,25 @@
|
|||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
import { keep, typo } from './typo';
|
||||||
|
|
||||||
|
const N = ' ';
|
||||||
|
|
||||||
|
describe('typo', () => {
|
||||||
|
it('короткие слова держатся за следующее', () => {
|
||||||
|
expect(typo('Привозим в пятницу и по Минску')).toBe(`Привозим в${N}пятницу и${N}по${N}Минску`);
|
||||||
|
});
|
||||||
|
it('в начале строки и с заглавной', () => {
|
||||||
|
expect(typo('В пятницу привозим')).toBe(`В${N}пятницу привозим`);
|
||||||
|
});
|
||||||
|
it('число и единица', () => {
|
||||||
|
expect(typo('партиями по 5 кг. Пачка 250 г, цена 32 BYN')).toBe(`партиями по${N}5${N}кг. Пачка 250${N}г, цена 32${N}BYN`);
|
||||||
|
});
|
||||||
|
it('не трогает слова, которые только начинаются как предлог', () => {
|
||||||
|
expect(typo('Иргачеффе вкусный')).toBe('Иргачеффе вкусный');
|
||||||
|
});
|
||||||
|
it('неразрывный дефис в фамилии', () => {
|
||||||
|
expect(typo('Scott Soltys-Curry')).toBe('Scott Soltys‑Curry');
|
||||||
|
});
|
||||||
|
it('keep склеивает фразу', () => {
|
||||||
|
expect(keep('красное яблоко')).toBe(`красное${N}яблоко`);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
// Типографика для вывода: неразрывные пробелы после коротких слов и между числом и единицей,
|
||||||
|
// неразрывный дефис в двойных латинских фамилиях. Смысл текста не меняется.
|
||||||
|
const NBSP = ' ';
|
||||||
|
const SHORT = 'а|в|во|и|к|ко|на|не|но|о|об|от|до|по|с|со|у|за|из|для|как|или';
|
||||||
|
const SHORT_RE = new RegExp(`(^|[\\s(«—–-])(${SHORT}) `, 'giu');
|
||||||
|
const UNIT_RE = /(\d) (кг|г|шт|BYN|мин|дн\.?|день|дня)(?=$|[\s.,:;!?)»])/gu;
|
||||||
|
const NAME_HYPHEN_RE = /([A-Z][a-z]+)-([A-Z][a-z]+)/g;
|
||||||
|
|
||||||
|
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');
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Склеить фразу целиком, чтобы она не рвалась: «красное яблоко». */
|
||||||
|
export function keep(text: string): string {
|
||||||
|
return text.replace(/ /g, NBSP);
|
||||||
|
}
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
// Скриншоты для критика: полная страница и первый экран на 390 и 1440.
|
// Скриншоты для критика: полная страница и первый экран на 390 и 1440, плюс кадр 390 со скроллом до y=1500.
|
||||||
// node scripts/screenshots.mjs <url> <каталог>
|
// node scripts/screenshots.mjs <url> <каталог>
|
||||||
// Анимации доигрываются: страница прокручивается до низа, чтобы сработали IntersectionObserver.
|
// Анимации доигрываются: страница прокручивается до низа, чтобы сработали IntersectionObserver.
|
||||||
import { mkdir } from 'node:fs/promises';
|
import { mkdir } from 'node:fs/promises';
|
||||||
@@ -22,6 +22,13 @@ for (const width of [390, 1440]) {
|
|||||||
await page.waitForTimeout(800);
|
await page.waitForTimeout(800);
|
||||||
await page.evaluate(() => window.scrollTo(0, 0));
|
await page.evaluate(() => window.scrollTo(0, 0));
|
||||||
await page.screenshot({ path: `${outDir}/${width}-full.png`, fullPage: true });
|
await page.screenshot({ path: `${outDir}/${width}-full.png`, fullPage: true });
|
||||||
|
if (width === 390) {
|
||||||
|
// третий кадр: середина страницы, видна липкая CTA
|
||||||
|
await page.evaluate(() => window.scrollTo(0, 1500));
|
||||||
|
await page.waitForTimeout(600);
|
||||||
|
await page.screenshot({ path: `${outDir}/${width}-scroll.png` });
|
||||||
|
await page.evaluate(() => window.scrollTo(0, 0));
|
||||||
|
}
|
||||||
const overflow = await page.evaluate(() => document.documentElement.scrollWidth - window.innerWidth);
|
const overflow = await page.evaluate(() => document.documentElement.scrollWidth - window.innerWidth);
|
||||||
console.log(`${width}: ${outDir}/${width}-full.png, горизонтальный выход за экран ${overflow}px`);
|
console.log(`${width}: ${outDir}/${width}-full.png, горизонтальный выход за экран ${overflow}px`);
|
||||||
await page.close();
|
await page.close();
|
||||||
|
|||||||
Reference in New Issue
Block a user