generated from VLADIMIR/template_frontend
up
This commit is contained in:
@@ -0,0 +1,301 @@
|
|||||||
|
# Дизайн-система сайта и игры «Вечерний детектив»
|
||||||
|
|
||||||
|
Два направления с общим логотипом: сайт помогает выбрать дело и управлять
|
||||||
|
участием, игра погружает в расследование. Визуальные правила описаны здесь;
|
||||||
|
единственный источник правил реализации — [FRONTEND_CODE_STANDARD.md](FRONTEND_CODE_STANDARD.md).
|
||||||
|
Рабочие страницы мигрируют постепенно; образцы не меняют их оформление.
|
||||||
|
|
||||||
|
## Области применения
|
||||||
|
|
||||||
|
| Направление | Где применяется | Характер |
|
||||||
|
| --------------------- | ----------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------- |
|
||||||
|
| Сайт «Золотой вечер» | Главная, каталог, кабинет, страницы организатора, редакторы и обычные формы | Тёмные ровные панели, машинописный шрифт, ярко-жёлтые действия, лёгкие фактуры |
|
||||||
|
| Игра «Материалы дела» | Расследование команды, улики, сюжетные документы, игровое управление и печатные материалы | Существующие бумага, кожа, металл, машинописный текст, бронзовые надписи и фактурные кнопки |
|
||||||
|
|
||||||
|
Превью сюжетного документа внутри каталога или редактора использует игровой
|
||||||
|
стиль; окружающие управление и формы — стиль сайта. Игровые изображения и
|
||||||
|
фоны сценариев сохраняют собственное оформление.
|
||||||
|
|
||||||
|
## Живые образцы
|
||||||
|
|
||||||
|
Запустить `npm run dev` и открыть `/design-system` на адресе Vite. Вкладка
|
||||||
|
«Сайт» открывается первой, «Игра» показывает отдельные материалы и управление,
|
||||||
|
«Компоненты» — последовательный каталог: заголовок компонента, живой пример,
|
||||||
|
описание контракта и код подключения; затем следующий компонент.
|
||||||
|
Страница доступна только в разработке, без входа и пункта основного меню;
|
||||||
|
маршрут и компоненты образцов исключены из production-сборки.
|
||||||
|
|
||||||
|
Все данные вымышлены. Форма, модальное окно и ход игры работают локально;
|
||||||
|
игровой ход не вызывает API и не меняет данные игры. Сайт использует `SiteThemeProvider`, игровые образцы —
|
||||||
|
собственный `NConfigProvider`: тема сайта не попадает в игровые компоненты.
|
||||||
|
|
||||||
|
## Общий бренд и логотип
|
||||||
|
|
||||||
|
Использовать [существующий знак](../src/assets/images/лого.jpg): детектив в
|
||||||
|
шляпе, чёрный круг, золотая окантовка. Не растягивать, не обрезать окантовку,
|
||||||
|
не перерисовывать. Свободное поле — не меньше четверти диаметра;
|
||||||
|
промежуток до названия — от 12 px. Размеры образцов — 48 и 144 px.
|
||||||
|
|
||||||
|
Изображение рядом с названием имеет пустой `alt`, самостоятельный знак —
|
||||||
|
описательный. JPEG сохраняет белый фон внутри круга, круглое размещение
|
||||||
|
допускает `border-radius: 50%`. Прозрачного и векторного исходника нет;
|
||||||
|
качество крупной печати и узнаваемость favicon проверяются отдельно.
|
||||||
|
|
||||||
|
Основной цвет текста обоих стилей на тёмных поверхностях — светлый `#EEE`,
|
||||||
|
второстепенный — `#BDB8AE`. Игровой бежевый `#BFA07D` используется точечно:
|
||||||
|
в бренде, декоративных надписях и текстовыделителе. Цвета статусов сохраняют
|
||||||
|
значение; текст на бумаге и жёлтых кнопках остаётся тёмным. Общий блок бренда
|
||||||
|
`DesignBrand` содержит логотип 48 px, промежуток 12 px и название
|
||||||
|
«Вечерний детектив» через `HeaderText`. На узком экране название может
|
||||||
|
переноситься; название направления «Материалы дела» располагается отдельно.
|
||||||
|
|
||||||
|
Общие правила: mobile-first, desktop от 1024 px, текущие контейнеры 700/1200 px,
|
||||||
|
явные состояния ошибки и загрузки, доступность с клавиатуры. Шкала отступов:
|
||||||
|
4, 8, 12, 16, 24, 32, 48, 64 px. Цвета задавать семантическими CSS-переменными.
|
||||||
|
|
||||||
|
## Сайт «Золотой вечер»
|
||||||
|
|
||||||
|
### Палитра
|
||||||
|
|
||||||
|
Токены находятся в `src/assets/base.css`. `--brand-bronze` и бумага связывают
|
||||||
|
оформление с игрой; жёлтый primary относится к сайту, а не к игровым действиям.
|
||||||
|
|
||||||
|
| CSS-переменная | Цвет | Назначение |
|
||||||
|
| ------------------------ | --------- | -------------------------------------------------- |
|
||||||
|
| `--brand-background` | `#111111` | Фон сайта; текст на золотой кнопке и бумаге |
|
||||||
|
| `--brand-surface` | `#222222` | Карточки, панели, окна |
|
||||||
|
| `--brand-surface-hover` | `#2E2E39` | Подсветка поверхности при наведении |
|
||||||
|
| `--brand-text` | `#EEEEEE` | Основной текст |
|
||||||
|
| `--brand-text-muted` | `#BDB8AE` | Пояснения, подписи, второстепенные данные |
|
||||||
|
| `--brand-accent` | `#FFD43B` | Основное действие, ссылки, активный элемент, focus |
|
||||||
|
| `--brand-accent-hover` | `#FFE066` | Наведение на основное действие |
|
||||||
|
| `--brand-accent-pressed` | `#F5C518` | Нажатие на основное действие |
|
||||||
|
| `--brand-border` | `#77737F` | Границы полей и панелей |
|
||||||
|
| `--brand-success` | `#87C99D` | Успех |
|
||||||
|
| `--brand-info` | `#94BFEE` | Информация |
|
||||||
|
| `--brand-error` | `#F29A9A` | Ошибка и опасное действие |
|
||||||
|
| `--brand-bronze` | `#BFA07D` | Бренд, текстовыделитель и декоративные надписи |
|
||||||
|
| `--brand-paper` | `#F4EDE0` | Бумажные материалы |
|
||||||
|
|
||||||
|
Предупреждение использует золото и явную подпись. Статусы не передавать только
|
||||||
|
цветом: добавлять текст и штатную иконку. Жёлтый акцент не использовать как цвет
|
||||||
|
обычного текста на бумаге или белом фоне.
|
||||||
|
|
||||||
|
## Типографика и компоновка
|
||||||
|
|
||||||
|
Весь стиль сайта использует существующий `font_old_typer`: заголовки, основной
|
||||||
|
текст, подписи, меню, кнопки, формы, таблицы и модальные окна. Новые шрифты не
|
||||||
|
загружаются. Шрифт задаётся на контейнере и в локальной теме Naive UI, чтобы
|
||||||
|
сохраняться в телепортируемых окнах. Игровая типографика остаётся прежней.
|
||||||
|
|
||||||
|
| Элемент | Размер | Вес | Межстрочный интервал |
|
||||||
|
| ----------------------------- | ------------------------ | ------------------------------- | -------------------- |
|
||||||
|
| Главный заголовок | `clamp(32px, 4vw, 48px)` | 600 | 1.15 |
|
||||||
|
| Заголовок раздела | 28 px | 600 | 1.3 |
|
||||||
|
| Заголовок блока | 22 px | 600 | 1.4 |
|
||||||
|
| Вступление | 18 px | 400 | 1.5 |
|
||||||
|
| Основной текст | 18 px | 400 | 1.5 |
|
||||||
|
| Подпись | 14 px | 400 | 1.5 |
|
||||||
|
| Короткая надпись над разделом | 14 px | 400 | 1.5 |
|
||||||
|
| Бумажный текст | 18 px | существующий вес `MessagePaper` | 1.5 |
|
||||||
|
|
||||||
|
Шкала отступов: 4, 8, 12, 16, 24, 32, 48, 64 px. На мобильном экране внешние
|
||||||
|
поля 16 px, на desktop — 24 px; между разделами 32 px на мобильном и 48 px на desktop, без сложения
|
||||||
|
верхних и нижних отступов соседних секций. Базовое скругление
|
||||||
|
8 px. Панели имеют тонкую границу; выраженную тень оставить бумажному слою и
|
||||||
|
существующим фактурным компонентам.
|
||||||
|
|
||||||
|
Использовать текущие контейнеры `.width700` для дел и `.width1200` для списков,
|
||||||
|
прокрутку `.center-block-custom` и шапку высотой 70 px. Mobile-first;
|
||||||
|
desktop-порог — `@media (width >= 1024px)`. До него колонки складываются
|
||||||
|
вертикально, кнопки переносятся; широкая таблица прокручивается внутри себя.
|
||||||
|
|
||||||
|
Главный блок выравнивается по верхнему краю, между колонками 32 px. Внутри
|
||||||
|
карточек поля 16 px на мобильном и 24 px на desktop, между элементами 16 px,
|
||||||
|
перед главным действием 24 px. Отступы абзацев по умолчанию нулевые;
|
||||||
|
интервалы задаются контейнерами. Не сжимать машинописный заголовок отрицательным
|
||||||
|
межбуквенным интервалом. Небольшие папки каталога сохраняют подписи не меньше 14 px.
|
||||||
|
|
||||||
|
### Компоненты и материалы
|
||||||
|
|
||||||
|
Использовать Naive UI. `NConfigProvider` берёт тему из CSS-токенов. Primary
|
||||||
|
имеет сплошную заливку `#FFD43B`, hover `#FFE066`, pressed `#F5C518`, текст
|
||||||
|
`#111`; сохранять машинописный шрифт и видимый focus. Опасное действие
|
||||||
|
использует красный акцент. Disabled и loading — штатные состояния Naive UI.
|
||||||
|
|
||||||
|
Главный блок, карточки, формы, таблицы и окна имеют ровные поверхности. Кожа
|
||||||
|
допускается как небольшая полоса в шапке (`BeltBlock`), бумага — в документальных фрагментах превью дела (`paper.jpg`);
|
||||||
|
не оформлять весь сайт стопками листов. Название с логотипом в обоих стилях использует общий `DesignBrand` и
|
||||||
|
существующий `HeaderText`: бежевый `#BFA07D`, машинописный шрифт 22 px,
|
||||||
|
вес 600 и разрядка 2 px. Меню и основной текст сайта используют `font_old_typer`.
|
||||||
|
|
||||||
|
Ссылки жёлтые на тёмном фоне; на светлом ярлыке текст тёмный. Формы имеют
|
||||||
|
подписи и ошибки у полей, таблицы — локальную прокрутку при необходимости.
|
||||||
|
Модальные окна — `n-modal` + `n-card`, заголовок, role dialog и aria-modal.
|
||||||
|
|
||||||
|
### Доска расследования
|
||||||
|
|
||||||
|
Без градиентов и свечения. Главный блок использует композицию
|
||||||
|
из текстовых улик: записка, билет, время отправления, номера и соединительные
|
||||||
|
линии приглушённого бронзового цвета. Логотип остаётся в шапке, не заменяет
|
||||||
|
обложку каждого дела. Под композицией — название, параметры и жёлтое действие.
|
||||||
|
|
||||||
|
На desktop фрагменты расположены асимметрично с небольшими поворотами;
|
||||||
|
на мобильном — вертикально, без наложения текста. Бумага использует существующую
|
||||||
|
фактуру. Машинописный шрифт допустим внутри документальных фрагментов;
|
||||||
|
описания, навигация, формы и кнопки тоже используют `font_old_typer`.
|
||||||
|
|
||||||
|
Документальные фрагменты декоративные, не имеют самостоятельных hover или действий. Линии не перехватывают
|
||||||
|
клики. Формы и служебные панели сохраняют ровные поверхности. Большая доска использует `CaseBoard`; каталог — повторяемые папки
|
||||||
|
`CaseFolder` с предметными обложками.
|
||||||
|
|
||||||
|
### Детали досье
|
||||||
|
|
||||||
|
| Приём | Где и как применять |
|
||||||
|
| -------------------- | --------------------------------------------------------------------------------------------------- |
|
||||||
|
| Папка с ярлыком | Карточка каталога, номер на выступающем ярлыке; оставлять 32 px между карточками |
|
||||||
|
| Бежевый маркер | Одно важное выражение в заголовке, тёмный текст на бежевой неровной полосе |
|
||||||
|
| Улика на обороте | Явная кнопка раскрывает цитату, `aria-expanded` и `aria-controls`; без 3D-переворота |
|
||||||
|
| Архивные разделители | Пунктир между разделами, номера и уголки на обложках; не дополнительные рамки вокруг каждого текста |
|
||||||
|
| Предметная обложка | Билет, ключ или письмо из существующих иконок и бумажных фактур; тот же символ рядом с названием |
|
||||||
|
| Заметка на полях | Короткая сюжетная подпись рядом с большой доской; на мобильном — ниже композиции |
|
||||||
|
| Штамп статуса | «Новое дело», «Скоро», «Пройдено»; только при наличии соответствующего реального состояния |
|
||||||
|
| Подсветка связи | Одна бронзовая линия становится жёлтой при hover или фокусе действия в карточке |
|
||||||
|
| Конец досье | Номер листа, название проекта, тонкая линия и «Продолжение — в следующем деле» |
|
||||||
|
|
||||||
|
Карточки поднимаются на 3 px при наведении мыши и фокусе внутри. Переходы
|
||||||
|
занимают 160 ms; при `prefers-reduced-motion` подъём и переходы отключаются,
|
||||||
|
раскрытие цитат продолжает работать. Touch не зависит от hover: цитата
|
||||||
|
открывается кнопкой. Декоративные слои и линии не перехватывают клики.
|
||||||
|
|
||||||
|
Три папки в образцах вымышлены, их штампы не обозначают реальные публикации
|
||||||
|
или историю пользователя. В образце дело «Скоро» имеет отключённое основное действие через отдельный
|
||||||
|
`disabled`; сам статус не управляет доступностью.
|
||||||
|
На рабочем сайте привязать состояния к данным; не вычислять «новое» или
|
||||||
|
«пройдено» по предположениям. Формы и таблицы сохраняют спокойный вид.
|
||||||
|
|
||||||
|
## Игра «Материалы дела»
|
||||||
|
|
||||||
|
### Эталон и палитра
|
||||||
|
|
||||||
|
Эталон — текущее оформление расследования команды, а не тема сайта.
|
||||||
|
Сохранить `MessagePaper`, `IntroBlock`, `GameInputForm`, `BeltBlock`,
|
||||||
|
`MetalPlate` и их фактуры. Окружение тёмное `#111`, панели `#222`, основной текст
|
||||||
|
`#EEE`, бумажный текст чёрный, декоративные надписи бронзовые `#BFA07D`.
|
||||||
|
Существующие мятные состояния и фактурные игровые кнопки сохраняются;
|
||||||
|
жёлтую тему сайта не передавать игровому provider.
|
||||||
|
|
||||||
|
### Материалы и управление
|
||||||
|
|
||||||
|
| Материал | Источник | Применение |
|
||||||
|
| ------------------ | ------------------------------------------------- | ------------------------------------------------------------ |
|
||||||
|
| Бумага | `MessagePaper`, `paper.jpg`, `paper_white.jpg` | Вступление, точки, улики и фотографии; сохранять слои и тени |
|
||||||
|
| Кожа | `BeltBlock`, `belt.png` | Полосы управления и фактурные действия |
|
||||||
|
| Металл | `MetalPlate`, `metal.png`, `pin.png` | Детали панелей, без заливки всей страницы |
|
||||||
|
| Игровое управление | `GameInputForm`, `input_center.png`, `button.png` | Место назначения, счётчики и фактурная кнопка |
|
||||||
|
|
||||||
|
Машинописный шрифт сохраняется в документах и существующих игровых
|
||||||
|
компонентах; сайт использует тот же шрифт по собственным правилам компоновки. `HeaderText` — для
|
||||||
|
бронзовых игровых надписей. В образцах подписи на коже и металле используют
|
||||||
|
чёрную подложку 80% для читаемости. Это локальное оформление образцов,
|
||||||
|
не массовая переделка рабочих игровых компонентов.
|
||||||
|
|
||||||
|
Вступление и улика используют настоящие компоненты с демонстрационными
|
||||||
|
данными. Панель хода повторяет поведение игры: успешный локальный ход очищает
|
||||||
|
поле и показывает результат. В образцах панель размещена в потоке страницы;
|
||||||
|
в рабочей игре остаётся её существующее фиксированное положение.
|
||||||
|
|
||||||
|
### Печатные материалы
|
||||||
|
|
||||||
|
Светлые внутренние страницы, тёмный текст, небольшие золотые метки. Тёмное
|
||||||
|
оформление допустимо для обложек. Не печатать свечение интерфейса. Номера,
|
||||||
|
подписи и статусы должны читаться в чёрно-белом виде; сюжетные изображения
|
||||||
|
сохраняют собственную стилистику.
|
||||||
|
|
||||||
|
## Проверка изменённого экрана
|
||||||
|
|
||||||
|
- На ширинах 375, 768 и 1280 px нет горизонтальной прокрутки всей страницы;
|
||||||
|
таблица может прокручиваться локально.
|
||||||
|
- Текст имеет контраст от 4.5:1; крупный текст и значимые границы управления —
|
||||||
|
от 3:1. Проверять реальные пары цветов, в том числе hover и pressed.
|
||||||
|
- Все действия доступны с клавиатуры; focus виден, окно закрывается и возвращает
|
||||||
|
фокус на открывший его элемент.
|
||||||
|
- Ошибка, успех, загрузка и пустое состояние различимы без цвета.
|
||||||
|
- Логотип не деформирован; весь UI сайта использует `font_old_typer`, включая окна.
|
||||||
|
- Компонентные стили локальны, глобальная тема изменяется только при осознанной
|
||||||
|
миграции. Типы, ESLint и Stylelint проходят; изменения маршрутов проверены сборкой.
|
||||||
|
|
||||||
|
- Переключение вкладок доступно с клавиатуры; сайт открывается первым.
|
||||||
|
- Жёлтая тема сайта не меняет игровые кнопки и документы.
|
||||||
|
- Игровой ход в образцах работает без сетевых запросов.
|
||||||
|
|
||||||
|
## Подключение библиотеки
|
||||||
|
|
||||||
|
Компоненты находятся в `src/components`, импортируются напрямую и не зависят
|
||||||
|
от dev-маршрута. Отдельного пакета и глобальной регистрации нет. Демонстрационные
|
||||||
|
данные остаются в `SiteDesignPreview`, `GameDesignPreview` и `ComponentDesignCatalog`.
|
||||||
|
Рабочие экраны пока не мигрируют.
|
||||||
|
|
||||||
|
```vue
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { NButton, NCard } from 'naive-ui'
|
||||||
|
import SiteThemeProvider from '@/components/SiteThemeProvider.vue'
|
||||||
|
import CaseFolder from '@/components/CaseFolder.vue'
|
||||||
|
|
||||||
|
function openCase(title: string) {
|
||||||
|
// Здесь родитель выполняет нужную навигацию или загрузку.
|
||||||
|
console.log(title)
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<SiteThemeProvider>
|
||||||
|
<NCard title="Материалы"><NButton type="primary">Открыть</NButton></NCard>
|
||||||
|
<CaseFolder
|
||||||
|
number="014"
|
||||||
|
title="Последний поезд"
|
||||||
|
description="Письмо и билет"
|
||||||
|
quote="Не доверяй времени"
|
||||||
|
object="ticket"
|
||||||
|
cover-label="Материал 014 / Билет"
|
||||||
|
cover-caption="23:40 · Вагон 03"
|
||||||
|
@open="openCase"
|
||||||
|
/>
|
||||||
|
</SiteThemeProvider>
|
||||||
|
</template>
|
||||||
|
```
|
||||||
|
|
||||||
|
Provider читает существующие CSS-токены при монтировании, задаёт darkTheme,
|
||||||
|
цвета, машинописный шрифт и размеры Naive UI; кнопки, поля, таблицы и окна
|
||||||
|
используются непосредственно из `naive-ui`. Телепортируемые окна сохраняют
|
||||||
|
тему; для текста внутри `NCard` используйте машинописный шрифт на классе окна,
|
||||||
|
как в каталоге. `--site-card-padding` позволяет контейнеру задавать поля карточек.
|
||||||
|
Игровые компоненты размещайте вне provider сайта, под своим
|
||||||
|
`NConfigProvider :theme="darkTheme" :theme-overrides="{}"`.
|
||||||
|
|
||||||
|
| Компонент | Props | Slots / события |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `SiteThemeProvider` | Нет | default / нет |
|
||||||
|
| `DesignBrand` | Нет | Нет |
|
||||||
|
| `CaseFolder` | number, title, description, quote, object: ticket/key/letter, coverLabel, coverCaption; status?, disabled=false, actionLabel='Открыть дело' | Нет / open(title: string) |
|
||||||
|
| `CaseBoard` | label, fragments: readonly массив { number, title, text, caption?, detail?, kind?: note/ticket/timetable }, footer? | footer / нет |
|
||||||
|
| `ObjectCover` | object: ticket/key/letter, label, caption, status? | Нет |
|
||||||
|
| `StatusStamp` | text? | default заменяет text / нет |
|
||||||
|
| `TextHighlight` | Нет | default / нет |
|
||||||
|
| `SectionHeading` | title, id?, eyebrow? | default заменяет title / нет |
|
||||||
|
| `DossierFooter` | label | default / нет |
|
||||||
|
| `MessagePaper` | Нет | default / нет |
|
||||||
|
| `HeaderText` | Нет | default / нет |
|
||||||
|
| `BeltBlock` | Нет | default / нет |
|
||||||
|
| `MetalPlate` | Нет | default / нет |
|
||||||
|
| `IntroBlock` | introduction: EditableIntroduction, mode: show/show-editor/edit | default в режиме edit / update:mode |
|
||||||
|
| `GameInputForm` | addAction: (code: string) => Promise<boolean>, allPlacesCount, newPlacesCount, gameStatus | Нет; true от addAction очищает поле |
|
||||||
|
|
||||||
|
Статус папки необязателен и только показывает текст. Доступность задаёт родитель
|
||||||
|
через `disabled`, подпись действия — через `actionLabel`. Событие `open` передаёт
|
||||||
|
название; маршрутизация и API остаются у родителя. Каждая цитата получает
|
||||||
|
уникальный Vue `useId()`, даже для повторных карточек одного дела.
|
||||||
|
|
||||||
|
Вкладки переключаются стрелками по кругу, Home открывает «Сайт», End —
|
||||||
|
«Компоненты». Каталог показывает disabled, loading, ошибку и успешную форму,
|
||||||
|
обрабатывает open локально и не отправляет запросы к серверу.
|
||||||
@@ -125,8 +125,10 @@
|
|||||||
`@media (width >= 1024px)`.
|
`@media (width >= 1024px)`.
|
||||||
- Сохраняй текущий flow: `HeaderMenu` сверху, `.center-block-custom` для
|
- Сохраняй текущий flow: `HeaderMenu` сверху, `.center-block-custom` для
|
||||||
прокрутки, `.width700` для дел и `.width1200` для списков.
|
прокрутки, `.width700` для дел и `.width1200` для списков.
|
||||||
- Бумажные листы используют `MessagePaper` и `font_old_typer`; UI-формы, меню и
|
- Бумажные листы используют `MessagePaper` и `font_old_typer`. В стиле сайта
|
||||||
кнопки этот шрифт не используют.
|
`font_old_typer` используется также для текста, меню, кнопок и форм согласно
|
||||||
|
`docs/DESIGN_SYSTEM.md` (выбор владельца от 14.10.2026). Игровые компоненты
|
||||||
|
сохраняют существующую типографику. Рабочие экраны мигрируют отдельно.
|
||||||
- Не добавляй глобальный utility-класс для одного компонента.
|
- Не добавляй глобальный utility-класс для одного компонента.
|
||||||
|
|
||||||
## 8. Формы, доступность и состояния
|
## 8. Формы, доступность и состояния
|
||||||
|
|||||||
@@ -1,5 +1,20 @@
|
|||||||
/* color palette from <https://github.com/vuejs/theme> */
|
/* color palette from <https://github.com/vuejs/theme> */
|
||||||
:root {
|
:root {
|
||||||
|
/* «Золотой вечер»: применяется при миграции экранов на дизайн-систему. */
|
||||||
|
--brand-background: #111;
|
||||||
|
--brand-surface: #222;
|
||||||
|
--brand-surface-hover: #2e2e39;
|
||||||
|
--brand-text: #eee;
|
||||||
|
--brand-text-muted: #bdb8ae;
|
||||||
|
--brand-accent: #ffd43b;
|
||||||
|
--brand-accent-hover: #ffe066;
|
||||||
|
--brand-accent-pressed: #f5c518;
|
||||||
|
--brand-border: #77737f;
|
||||||
|
--brand-success: #87c99d;
|
||||||
|
--brand-info: #94bfee;
|
||||||
|
--brand-error: #f29a9a;
|
||||||
|
--brand-paper: #f4ede0;
|
||||||
|
--brand-bronze: #bfa07d;
|
||||||
--vt-c-white: #fff;
|
--vt-c-white: #fff;
|
||||||
--vt-c-white-soft: #f8f8f8;
|
--vt-c-white-soft: #f8f8f8;
|
||||||
--vt-c-white-mute: #f2f2f2;
|
--vt-c-white-mute: #f2f2f2;
|
||||||
|
|||||||
@@ -0,0 +1,179 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
defineProps<{
|
||||||
|
label: string
|
||||||
|
footer?: string
|
||||||
|
fragments: ReadonlyArray<{
|
||||||
|
number: string
|
||||||
|
title: string
|
||||||
|
text: string
|
||||||
|
caption?: string
|
||||||
|
detail?: string
|
||||||
|
kind?: 'note' | 'ticket' | 'timetable'
|
||||||
|
}>
|
||||||
|
}>()
|
||||||
|
</script>
|
||||||
|
<template>
|
||||||
|
<div class="case-board">
|
||||||
|
<p class="board-label">{{ label }}</p>
|
||||||
|
<div class="clues">
|
||||||
|
<article
|
||||||
|
v-for="(fragment, index) in fragments"
|
||||||
|
:key="index"
|
||||||
|
class="clue"
|
||||||
|
:class="fragment.kind"
|
||||||
|
>
|
||||||
|
<span class="clue-number">{{ fragment.number }} / {{ fragment.title }}</span>
|
||||||
|
<p
|
||||||
|
:class="{
|
||||||
|
'ticket-route': fragment.kind === 'ticket',
|
||||||
|
'departure-time': fragment.kind === 'timetable',
|
||||||
|
}"
|
||||||
|
>
|
||||||
|
{{ fragment.text }}
|
||||||
|
</p>
|
||||||
|
<p v-if="fragment.detail" class="ticket-time">{{ fragment.detail }}</p>
|
||||||
|
<span v-if="fragment.caption" class="clue-caption">{{ fragment.caption }}</span>
|
||||||
|
</article>
|
||||||
|
</div>
|
||||||
|
<p v-if="footer || $slots.footer" class="board-footer">
|
||||||
|
<slot name="footer">{{ footer }}</slot>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<style scoped>
|
||||||
|
.case-board {
|
||||||
|
background: var(--brand-background);
|
||||||
|
border: 1px solid var(--brand-border);
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 24px 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.board-label,
|
||||||
|
.board-footer {
|
||||||
|
color: var(--brand-text-muted);
|
||||||
|
font-size: 14px;
|
||||||
|
letter-spacing: 0.08em;
|
||||||
|
margin: 0;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
|
||||||
|
.board-footer {
|
||||||
|
margin-top: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.clues {
|
||||||
|
display: grid;
|
||||||
|
gap: 24px;
|
||||||
|
margin-top: 24px;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.clues::before {
|
||||||
|
background: var(--brand-bronze);
|
||||||
|
bottom: 16px;
|
||||||
|
content: '';
|
||||||
|
left: 50%;
|
||||||
|
pointer-events: none;
|
||||||
|
position: absolute;
|
||||||
|
top: 16px;
|
||||||
|
transition: background-color 160ms ease;
|
||||||
|
width: 1px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.case-board:focus-within .clues::before {
|
||||||
|
background: var(--brand-accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (hover: hover) {
|
||||||
|
.case-board:hover .clues::before {
|
||||||
|
background: var(--brand-accent);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (prefers-reduced-motion: reduce) {
|
||||||
|
.clues::before {
|
||||||
|
transition: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.clue {
|
||||||
|
background: var(--brand-paper) url('@/assets/images/paper.jpg') center / cover;
|
||||||
|
box-shadow: 0 4px 8px rgb(0 0 0 / 35%);
|
||||||
|
color: var(--brand-background);
|
||||||
|
padding: 16px;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.clue::before {
|
||||||
|
background: var(--brand-bronze);
|
||||||
|
border: 2px solid var(--brand-background);
|
||||||
|
border-radius: 50%;
|
||||||
|
content: '';
|
||||||
|
height: 8px;
|
||||||
|
left: calc(50% - 6px);
|
||||||
|
position: absolute;
|
||||||
|
top: -6px;
|
||||||
|
width: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.clue-number,
|
||||||
|
.clue-caption {
|
||||||
|
display: block;
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.clue-number {
|
||||||
|
border-bottom: 1px dashed var(--brand-background);
|
||||||
|
margin-bottom: 12px;
|
||||||
|
padding-bottom: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.clue p {
|
||||||
|
font-family: 'font_old_typer', serif;
|
||||||
|
font-size: 17px;
|
||||||
|
line-height: 1.5;
|
||||||
|
margin: 0 0 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.clue .ticket-route {
|
||||||
|
font-family: inherit;
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.clue .ticket-time,
|
||||||
|
.clue .departure-time {
|
||||||
|
font-size: 32px;
|
||||||
|
line-height: 1.1;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (width >= 1024px) {
|
||||||
|
.clues {
|
||||||
|
gap: 24px;
|
||||||
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
|
}
|
||||||
|
|
||||||
|
.clues::before {
|
||||||
|
height: 1px;
|
||||||
|
left: 20%;
|
||||||
|
top: 65%;
|
||||||
|
transform: rotate(-20deg);
|
||||||
|
width: 65%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.note {
|
||||||
|
grid-column: 1 / -1;
|
||||||
|
margin-right: 24px;
|
||||||
|
transform: rotate(-2deg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ticket {
|
||||||
|
transform: rotate(3deg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.timetable {
|
||||||
|
margin-top: 24px;
|
||||||
|
transform: rotate(-3deg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,122 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { KeyOutline, MailOutline, TicketOutline } from '@vicons/ionicons5'
|
||||||
|
import { Icon } from '@vicons/utils'
|
||||||
|
import { NButton, NCard } from 'naive-ui'
|
||||||
|
import { ref, useId } from 'vue'
|
||||||
|
|
||||||
|
import ObjectCover from '@/components/ObjectCover.vue'
|
||||||
|
|
||||||
|
const props = withDefaults(
|
||||||
|
defineProps<{
|
||||||
|
number: string
|
||||||
|
title: string
|
||||||
|
description: string
|
||||||
|
quote: string
|
||||||
|
object: 'ticket' | 'key' | 'letter'
|
||||||
|
status?: string
|
||||||
|
coverLabel: string
|
||||||
|
coverCaption: string
|
||||||
|
disabled?: boolean
|
||||||
|
actionLabel?: string
|
||||||
|
}>(),
|
||||||
|
{ disabled: false, actionLabel: 'Открыть дело' },
|
||||||
|
)
|
||||||
|
const emit = defineEmits<{ open: [title: string] }>()
|
||||||
|
const clueId = useId()
|
||||||
|
const isClueOpen = ref(false)
|
||||||
|
const icons = { ticket: TicketOutline, key: KeyOutline, letter: MailOutline }
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<n-card class="case-folder" :content-style="{ display: 'grid', gap: '16px' }">
|
||||||
|
<span class="folder-tab">Дело / {{ number }}</span>
|
||||||
|
<ObjectCover :object="object" :label="coverLabel" :caption="coverCaption" :status="status" />
|
||||||
|
<h3 class="folder-title">
|
||||||
|
<Icon :size="20" aria-hidden="true"><component :is="icons[props.object]" /></Icon>{{ title }}
|
||||||
|
</h3>
|
||||||
|
<p class="folder-description">{{ description }}</p>
|
||||||
|
<n-button
|
||||||
|
class="clue-toggle"
|
||||||
|
:aria-expanded="isClueOpen"
|
||||||
|
:aria-controls="clueId"
|
||||||
|
@click="isClueOpen = !isClueOpen"
|
||||||
|
>{{ isClueOpen ? 'Скрыть улику' : 'Посмотреть улику' }}</n-button
|
||||||
|
>
|
||||||
|
<blockquote v-show="isClueOpen" :id="clueId" class="clue-quote">{{ quote }}</blockquote>
|
||||||
|
<n-button
|
||||||
|
type="primary"
|
||||||
|
class="folder-action"
|
||||||
|
:disabled="disabled"
|
||||||
|
@click="emit('open', title)"
|
||||||
|
>{{ actionLabel }}</n-button
|
||||||
|
>
|
||||||
|
</n-card>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.case-folder {
|
||||||
|
margin-top: 24px;
|
||||||
|
min-width: 0;
|
||||||
|
position: relative;
|
||||||
|
transition: transform 160ms ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.folder-tab {
|
||||||
|
background: var(--brand-surface);
|
||||||
|
border: 1px solid var(--brand-border);
|
||||||
|
border-bottom: 0;
|
||||||
|
border-radius: 6px 6px 0 0;
|
||||||
|
color: var(--brand-text-muted);
|
||||||
|
font-size: 14px;
|
||||||
|
left: 16px;
|
||||||
|
padding: 4px 12px;
|
||||||
|
position: absolute;
|
||||||
|
top: -30px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.folder-title {
|
||||||
|
align-items: center;
|
||||||
|
display: flex;
|
||||||
|
font-size: 22px;
|
||||||
|
gap: 8px;
|
||||||
|
line-height: 1.3;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.folder-description {
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.clue-quote {
|
||||||
|
border-left: 2px solid var(--brand-accent);
|
||||||
|
color: var(--brand-text);
|
||||||
|
font-size: 18px;
|
||||||
|
margin: 0;
|
||||||
|
padding-left: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.folder-action {
|
||||||
|
margin-top: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.case-folder:focus-within {
|
||||||
|
transform: translateY(-3px);
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (hover: hover) {
|
||||||
|
.case-folder:hover {
|
||||||
|
transform: translateY(-3px);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (prefers-reduced-motion: reduce) {
|
||||||
|
.case-folder {
|
||||||
|
transition: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.case-folder:hover,
|
||||||
|
.case-folder:focus-within {
|
||||||
|
transform: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,480 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import {
|
||||||
|
darkTheme,
|
||||||
|
NAlert,
|
||||||
|
NButton,
|
||||||
|
NCard,
|
||||||
|
NConfigProvider,
|
||||||
|
NDataTable,
|
||||||
|
NFormItem,
|
||||||
|
NInput,
|
||||||
|
NModal,
|
||||||
|
} from 'naive-ui'
|
||||||
|
import { computed, ref } from 'vue'
|
||||||
|
|
||||||
|
import BeltBlock from '@/components/BeltBlock.vue'
|
||||||
|
import CaseBoard from '@/components/CaseBoard.vue'
|
||||||
|
import CaseFolder from '@/components/CaseFolder.vue'
|
||||||
|
import DesignBrand from '@/components/DesignBrand.vue'
|
||||||
|
import DossierFooter from '@/components/DossierFooter.vue'
|
||||||
|
import GameInputForm from '@/components/GameInputForm.vue'
|
||||||
|
import HeaderText from '@/components/HeaderText.vue'
|
||||||
|
import IntroBlock from '@/components/IntroBlock.vue'
|
||||||
|
import MessagePaper from '@/components/MessagePaper.vue'
|
||||||
|
import MetalPlate from '@/components/MetalPlate.vue'
|
||||||
|
import ObjectCover from '@/components/ObjectCover.vue'
|
||||||
|
import SectionHeading from '@/components/SectionHeading.vue'
|
||||||
|
import SiteThemeProvider from '@/components/SiteThemeProvider.vue'
|
||||||
|
import StatusStamp from '@/components/StatusStamp.vue'
|
||||||
|
import TextHighlight from '@/components/TextHighlight.vue'
|
||||||
|
const entries = [
|
||||||
|
{
|
||||||
|
name: 'SiteThemeProvider',
|
||||||
|
description:
|
||||||
|
'Без props и событий; default slot. Тема Naive UI и машинописная типографика сайта.',
|
||||||
|
example:
|
||||||
|
"import SiteThemeProvider from '@/components/SiteThemeProvider.vue'\nimport { NButton } from 'naive-ui'\n\n<SiteThemeProvider><NButton type=\"primary\">Открыть</NButton></SiteThemeProvider>",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'DesignBrand',
|
||||||
|
description: 'Без props и событий; общий логотип и название.',
|
||||||
|
example: "import DesignBrand from '@/components/DesignBrand.vue'\n\n<DesignBrand />",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'CaseFolder',
|
||||||
|
description:
|
||||||
|
'Обязательные: number, title, description, quote, object (ticket/key/letter), coverLabel, coverCaption. Необязательные: status, disabled=false, actionLabel. Событие open(title). Без slots; статус не запрещает действие.',
|
||||||
|
example:
|
||||||
|
'import CaseFolder from \'@/components/CaseFolder.vue\'\n\nfunction openCase(title: string) {\n console.log(title)\n}\n\n<CaseFolder number="014" title="Поезд" description="Описание" quote="Улика" object="ticket" cover-label="Билет" cover-caption="23:40" @open="openCase" />',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'CaseBoard',
|
||||||
|
description:
|
||||||
|
'label, fragments: массив { number, title, text, caption?, detail?, kind?: note/ticket/timetable }; footer необязателен. Slot footer, без событий.',
|
||||||
|
example:
|
||||||
|
"import CaseBoard from '@/components/CaseBoard.vue'\n\n<CaseBoard label=\"Дело 014\" :fragments=\"[{ number: '01', title: 'Записка', text: 'Текст улики' }]\" />",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'ObjectCover',
|
||||||
|
description:
|
||||||
|
'object: ticket/key/letter, label, caption; status необязателен. Без slots и событий.',
|
||||||
|
example:
|
||||||
|
'import ObjectCover from \'@/components/ObjectCover.vue\'\n\n<ObjectCover object="key" label="Ключ" caption="Комната 7" />',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'StatusStamp',
|
||||||
|
description: 'Необязательный text; default slot заменяет текст. Без событий.',
|
||||||
|
example:
|
||||||
|
'import StatusStamp from \'@/components/StatusStamp.vue\'\n\n<StatusStamp text="В работе" />',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'TextHighlight',
|
||||||
|
description: 'Без props и событий; default slot для произвольного текста.',
|
||||||
|
example:
|
||||||
|
"import TextHighlight from '@/components/TextHighlight.vue'\n\n<TextHighlight>Важная деталь</TextHighlight>",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'SectionHeading',
|
||||||
|
description:
|
||||||
|
'title; необязательные id и eyebrow. Default slot заменяет заголовок. Без событий.',
|
||||||
|
example:
|
||||||
|
'import SectionHeading from \'@/components/SectionHeading.vue\'\n\n<SectionHeading title="Материалы" eyebrow="01 / Дело" />',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'DossierFooter',
|
||||||
|
description: 'label; default slot для содержимого. Без событий.',
|
||||||
|
example:
|
||||||
|
'import DossierFooter from \'@/components/DossierFooter.vue\'\n\n<DossierFooter label="Лист 01">Продолжение следует.</DossierFooter>',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'MessagePaper',
|
||||||
|
description: 'Без props и событий; default slot — бумажный документ.',
|
||||||
|
example:
|
||||||
|
"import MessagePaper from '@/components/MessagePaper.vue'\n\n<MessagePaper>Текст письма</MessagePaper>",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'HeaderText',
|
||||||
|
description: 'Без props и событий; default slot — игровая надпись.',
|
||||||
|
example:
|
||||||
|
"import HeaderText from '@/components/HeaderText.vue'\n\n<HeaderText>Расследование</HeaderText>",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'BeltBlock',
|
||||||
|
description: 'Без props и событий; default slot — содержимое кожаной полосы.',
|
||||||
|
example:
|
||||||
|
"import BeltBlock from '@/components/BeltBlock.vue'\nimport HeaderText from '@/components/HeaderText.vue'\n\n<BeltBlock><HeaderText>Панель</HeaderText></BeltBlock>",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'MetalPlate',
|
||||||
|
description: 'Без props и событий; default slot — содержимое металлической панели.',
|
||||||
|
example:
|
||||||
|
"import MetalPlate from '@/components/MetalPlate.vue'\n\n<MetalPlate>Деталь панели</MetalPlate>",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'IntroBlock',
|
||||||
|
description:
|
||||||
|
'introduction: EditableIntroduction, mode: show/show-editor/edit. Событие update:mode; default slot в редакторе. В каталоге только просмотр.',
|
||||||
|
example:
|
||||||
|
"import IntroBlock from '@/components/IntroBlock.vue'\n\n<IntroBlock :introduction=\"{ text: 'Вступление', audio: '' }\" mode=\"show\" />",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'GameInputForm',
|
||||||
|
description:
|
||||||
|
'addAction: (code: string) => Promise<boolean>, allPlacesCount, newPlacesCount, gameStatus. Без slots и событий. true очищает ввод, false сохраняет.',
|
||||||
|
example:
|
||||||
|
'import GameInputForm from \'@/components/GameInputForm.vue\'\n\nasync function demoAction(code: string): Promise<boolean> {\n console.log(code)\n return true\n}\n\n<GameInputForm :add-action="demoAction" :all-places-count="3" :new-places-count="1" game-status="run" />',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'NButton',
|
||||||
|
description:
|
||||||
|
'Naive UI: type, disabled, loading; default slot, событие click. Использовать под SiteThemeProvider.',
|
||||||
|
example: `import { NButton } from 'naive-ui'
|
||||||
|
|
||||||
|
<NButton type="primary" @click="openCase('Пример окна')">Открыть окно</NButton>
|
||||||
|
<NButton disabled>Недоступно</NButton>
|
||||||
|
<NButton loading>Загрузка</NButton>`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'NInput / NFormItem',
|
||||||
|
description:
|
||||||
|
'Naive UI: value и update:value у поля; label, validationStatus и feedback у NFormItem. Форма проверяется локально.',
|
||||||
|
example: `import { NInput, NFormItem, NButton } from 'naive-ui'
|
||||||
|
import { computed, ref } from 'vue'
|
||||||
|
|
||||||
|
const teamName = ref('')
|
||||||
|
const isSubmitted = ref(false)
|
||||||
|
const hasNameError = computed(() => isSubmitted.value && !teamName.value.trim())
|
||||||
|
|
||||||
|
<form @submit.prevent="isSubmitted = true">
|
||||||
|
<NFormItem label="Команда" :validation-status="hasNameError ? 'error' : undefined" :feedback="hasNameError ? 'Укажите название команды' : undefined">
|
||||||
|
<NInput v-model:value="teamName" />
|
||||||
|
</NFormItem>
|
||||||
|
<NButton attr-type="submit">Проверить форму</NButton>
|
||||||
|
</form>`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'NDataTable',
|
||||||
|
description:
|
||||||
|
'Naive UI: columns, data, scrollX; широкая таблица прокручивается внутри своего блока.',
|
||||||
|
example: `import { NDataTable } from 'naive-ui'
|
||||||
|
|
||||||
|
const columns = [{ title: 'Команда', key: 'name' }, { title: 'Статус', key: 'status' }]
|
||||||
|
const teams = [{ name: 'Детективы', status: 'В работе' }]
|
||||||
|
|
||||||
|
<NDataTable :columns="columns" :data="teams" :scroll-x="480" />`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'NModal / NCard',
|
||||||
|
description:
|
||||||
|
'Naive UI: show и update:show у окна; title, closable, footer slot и close у карточки. Закрывается Escape и возвращает фокус.',
|
||||||
|
example: `import { NModal, NCard, NButton } from 'naive-ui'
|
||||||
|
import { ref } from 'vue'
|
||||||
|
|
||||||
|
const isModalOpen = ref(false)
|
||||||
|
|
||||||
|
<NButton @click="isModalOpen = true">Открыть окно</NButton>
|
||||||
|
<NModal v-model:show="isModalOpen">
|
||||||
|
<NCard title="Дело" role="dialog" aria-modal="true" aria-label="Дело" closable @close="isModalOpen = false">
|
||||||
|
Материалы дела
|
||||||
|
<template #footer><NButton @click="isModalOpen = false">Закрыть</NButton></template>
|
||||||
|
</NCard>
|
||||||
|
</NModal>`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'NAlert',
|
||||||
|
description:
|
||||||
|
'Naive UI: type и title; default slot для сообщения. Ошибка обозначена текстом и иконкой.',
|
||||||
|
example: `import { NAlert } from 'naive-ui'
|
||||||
|
|
||||||
|
<NAlert type="error">Не удалось загрузить материалы.</NAlert>`,
|
||||||
|
},
|
||||||
|
]
|
||||||
|
const lastCode = ref('')
|
||||||
|
async function demoAction(code: string): Promise<boolean> {
|
||||||
|
lastCode.value = code
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
const isModalOpen = ref(false)
|
||||||
|
const selectedTitle = ref('')
|
||||||
|
const teamName = ref('')
|
||||||
|
const isSubmitted = ref(false)
|
||||||
|
const hasNameError = computed(() => isSubmitted.value && !teamName.value.trim())
|
||||||
|
function openCase(title: string) {
|
||||||
|
selectedTitle.value = title
|
||||||
|
isModalOpen.value = true
|
||||||
|
}
|
||||||
|
function submitDemo() {
|
||||||
|
isSubmitted.value = true
|
||||||
|
}
|
||||||
|
const fragments = [
|
||||||
|
{ number: 'A', title: 'Ключ', text: 'Найден у двери', caption: 'Другие данные' },
|
||||||
|
{ number: 'B', title: 'Письмо', text: 'Встреча в саду' },
|
||||||
|
]
|
||||||
|
const columns = [
|
||||||
|
{ title: 'Команда', key: 'name' },
|
||||||
|
{ title: 'Статус', key: 'status' },
|
||||||
|
]
|
||||||
|
const teams = [{ name: 'Детективы', status: 'В работе' }]
|
||||||
|
</script>
|
||||||
|
<template>
|
||||||
|
<SiteThemeProvider>
|
||||||
|
<div class="catalog width1200">
|
||||||
|
<SectionHeading title="Компоненты" eyebrow="Библиотека / Прямые импорты" />
|
||||||
|
<p>
|
||||||
|
Примеры работают локально. Компоненты библиотеки доступны рабочим страницам независимо от
|
||||||
|
каталога.
|
||||||
|
</p>
|
||||||
|
<section
|
||||||
|
v-for="entry in entries"
|
||||||
|
:key="entry.name"
|
||||||
|
class="catalog-entry"
|
||||||
|
:data-component="entry.name"
|
||||||
|
>
|
||||||
|
<h2>{{ entry.name }}</h2>
|
||||||
|
<n-config-provider
|
||||||
|
v-if="
|
||||||
|
[
|
||||||
|
'MessagePaper',
|
||||||
|
'HeaderText',
|
||||||
|
'BeltBlock',
|
||||||
|
'MetalPlate',
|
||||||
|
'IntroBlock',
|
||||||
|
'GameInputForm',
|
||||||
|
].includes(entry.name)
|
||||||
|
"
|
||||||
|
:theme="darkTheme"
|
||||||
|
:theme-overrides="{}"
|
||||||
|
>
|
||||||
|
<div class="game-preview">
|
||||||
|
<MessagePaper v-if="entry.name === 'MessagePaper'">Текст письма</MessagePaper>
|
||||||
|
<HeaderText v-else-if="entry.name === 'HeaderText'">Расследование</HeaderText>
|
||||||
|
<BeltBlock v-else-if="entry.name === 'BeltBlock'" class="material-belt"
|
||||||
|
><div class="material-label"><HeaderText>Панель</HeaderText></div></BeltBlock
|
||||||
|
>
|
||||||
|
<MetalPlate v-else-if="entry.name === 'MetalPlate'"
|
||||||
|
><div class="material-label">Деталь панели</div></MetalPlate
|
||||||
|
>
|
||||||
|
<IntroBlock
|
||||||
|
v-else-if="entry.name === 'IntroBlock'"
|
||||||
|
:introduction="{ text: 'Вступление', audio: '' }"
|
||||||
|
mode="show"
|
||||||
|
/>
|
||||||
|
<template v-else>
|
||||||
|
<div class="game-controls">
|
||||||
|
<GameInputForm
|
||||||
|
:add-action="demoAction"
|
||||||
|
:all-places-count="3"
|
||||||
|
:new-places-count="1"
|
||||||
|
game-status="run"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<n-alert v-if="lastCode" class="action-result" type="success" role="status"
|
||||||
|
>Ход «{{ lastCode }}» принят локально.</n-alert
|
||||||
|
>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
</n-config-provider>
|
||||||
|
<div v-else class="component-sample">
|
||||||
|
<n-button
|
||||||
|
v-if="entry.name === 'SiteThemeProvider'"
|
||||||
|
type="primary"
|
||||||
|
@click="openCase('Тема сайта')"
|
||||||
|
>Открыть</n-button
|
||||||
|
>
|
||||||
|
<DesignBrand v-else-if="entry.name === 'DesignBrand'" />
|
||||||
|
<template v-else-if="entry.name === 'CaseFolder'">
|
||||||
|
<p>
|
||||||
|
Две папки одного дела: цитаты независимы. Статус «Скоро» у первой не блокирует
|
||||||
|
действие; вторая без статуса и с disabled.
|
||||||
|
</p>
|
||||||
|
<div class="folders">
|
||||||
|
<CaseFolder
|
||||||
|
number="014"
|
||||||
|
title="Повторное дело"
|
||||||
|
description="Первый экземпляр"
|
||||||
|
quote="Первая цитата"
|
||||||
|
object="ticket"
|
||||||
|
cover-label="Другой билет"
|
||||||
|
cover-caption="10:15"
|
||||||
|
status="Скоро"
|
||||||
|
@open="openCase"
|
||||||
|
/>
|
||||||
|
<CaseFolder
|
||||||
|
number="014"
|
||||||
|
title="Повторное дело"
|
||||||
|
description="Второй экземпляр"
|
||||||
|
quote="Вторая цитата"
|
||||||
|
object="letter"
|
||||||
|
cover-label="Другое письмо"
|
||||||
|
cover-caption="Адрес известен"
|
||||||
|
disabled
|
||||||
|
@open="openCase"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<CaseBoard
|
||||||
|
v-else-if="entry.name === 'CaseBoard'"
|
||||||
|
label="Другое расследование"
|
||||||
|
:fragments="fragments"
|
||||||
|
><template #footer>Произвольный итог доски.</template></CaseBoard
|
||||||
|
>
|
||||||
|
<ObjectCover
|
||||||
|
v-else-if="entry.name === 'ObjectCover'"
|
||||||
|
object="key"
|
||||||
|
label="Отдельная обложка"
|
||||||
|
caption="Чердак"
|
||||||
|
/>
|
||||||
|
<StatusStamp v-else-if="entry.name === 'StatusStamp'" text="Проверено" />
|
||||||
|
<TextHighlight v-else-if="entry.name === 'TextHighlight'">Важная деталь</TextHighlight>
|
||||||
|
<SectionHeading
|
||||||
|
v-else-if="entry.name === 'SectionHeading'"
|
||||||
|
title="Материалы"
|
||||||
|
eyebrow="01 / Дело"
|
||||||
|
/>
|
||||||
|
<DossierFooter v-else-if="entry.name === 'DossierFooter'" label="Лист 01"
|
||||||
|
>Продолжение следует.</DossierFooter
|
||||||
|
>
|
||||||
|
<template v-else-if="entry.name === 'NButton'">
|
||||||
|
<div class="buttons">
|
||||||
|
<n-button type="primary" @click="openCase('Пример окна')">Открыть окно</n-button
|
||||||
|
><n-button disabled>Недоступно</n-button><n-button loading>Загрузка</n-button>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<template v-else-if="entry.name === 'NInput / NFormItem'">
|
||||||
|
<form novalidate @submit.prevent="submitDemo">
|
||||||
|
<n-form-item
|
||||||
|
label="Название команды"
|
||||||
|
label-for="catalog-team"
|
||||||
|
:validation-status="hasNameError ? 'error' : undefined"
|
||||||
|
:feedback="hasNameError ? 'Укажите название команды' : undefined"
|
||||||
|
>
|
||||||
|
<n-input
|
||||||
|
v-model:value="teamName"
|
||||||
|
:input-props="{ id: 'catalog-team', required: true }"
|
||||||
|
/> </n-form-item
|
||||||
|
><n-button type="primary" attr-type="submit">Проверить форму</n-button>
|
||||||
|
<n-alert v-if="isSubmitted && !hasNameError" type="success" role="status"
|
||||||
|
>Команда «{{ teamName.trim() }}» готова.</n-alert
|
||||||
|
>
|
||||||
|
</form>
|
||||||
|
</template>
|
||||||
|
<n-data-table
|
||||||
|
v-else-if="entry.name === 'NDataTable'"
|
||||||
|
:columns="columns"
|
||||||
|
:data="teams"
|
||||||
|
:scroll-x="480"
|
||||||
|
/>
|
||||||
|
<n-button v-else-if="entry.name === 'NModal / NCard'" @click="openCase('Пример окна')"
|
||||||
|
>Открыть окно</n-button
|
||||||
|
>
|
||||||
|
<n-alert v-else-if="entry.name === 'NAlert'" type="error"
|
||||||
|
>Пример ошибки загрузки материалов.</n-alert
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
<p>{{ entry.description }}</p>
|
||||||
|
<pre><code>{{ entry.example }}</code></pre>
|
||||||
|
</section>
|
||||||
|
<n-modal v-model:show="isModalOpen"
|
||||||
|
><n-card
|
||||||
|
class="catalog-modal"
|
||||||
|
:title="selectedTitle"
|
||||||
|
role="dialog"
|
||||||
|
aria-modal="true"
|
||||||
|
:aria-label="selectedTitle"
|
||||||
|
closable
|
||||||
|
@close="isModalOpen = false"
|
||||||
|
>Событие обработано локально.<template #footer
|
||||||
|
><n-button type="primary" @click="isModalOpen = false">Закрыть</n-button></template
|
||||||
|
></n-card
|
||||||
|
></n-modal
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
</SiteThemeProvider>
|
||||||
|
</template>
|
||||||
|
<style scoped>
|
||||||
|
.catalog {
|
||||||
|
display: grid;
|
||||||
|
gap: 24px;
|
||||||
|
grid-template-columns: minmax(0, 1fr);
|
||||||
|
max-width: 100%;
|
||||||
|
padding: 24px 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.folders {
|
||||||
|
display: grid;
|
||||||
|
gap: 32px;
|
||||||
|
grid-template-columns: minmax(0, 1fr);
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.catalog-entry {
|
||||||
|
border-bottom: 1px dashed var(--brand-border);
|
||||||
|
display: grid;
|
||||||
|
gap: 24px;
|
||||||
|
grid-template-columns: minmax(0, 1fr);
|
||||||
|
min-width: 0;
|
||||||
|
padding-bottom: 32px;
|
||||||
|
}
|
||||||
|
|
||||||
|
h2 {
|
||||||
|
font-size: 28px;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.component-sample {
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.game-controls {
|
||||||
|
margin: 48px 30px 0;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* stylelint-disable-next-line selector-pseudo-class-no-unknown -- Панель образца располагается в потоке каталога. */
|
||||||
|
.game-controls :deep(.form-custom) {
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.material-belt {
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.material-label {
|
||||||
|
background: rgb(0 0 0 / 80%);
|
||||||
|
color: var(--brand-bronze);
|
||||||
|
padding: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.action-result {
|
||||||
|
margin-top: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
pre {
|
||||||
|
font-size: 14px;
|
||||||
|
max-width: 100%;
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
white-space: pre-wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
p {
|
||||||
|
margin: 0 0 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.buttons {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.catalog-modal {
|
||||||
|
font-family: 'font_old_typer', serif;
|
||||||
|
max-width: 560px;
|
||||||
|
width: calc(100vw - 32px);
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (width >= 1024px) {
|
||||||
|
.folders {
|
||||||
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import logo from '@/assets/images/лого.jpg'
|
||||||
|
import HeaderText from '@/components/HeaderText.vue'
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<span class="design-brand">
|
||||||
|
<img :src="logo" alt="" width="48" height="48" />
|
||||||
|
<span class="brand-name"><HeaderText>Вечерний детектив</HeaderText></span>
|
||||||
|
</span>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.design-brand {
|
||||||
|
align-items: center;
|
||||||
|
display: flex;
|
||||||
|
gap: 12px;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.design-brand img {
|
||||||
|
border-radius: 50%;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.brand-name {
|
||||||
|
line-height: 1.3;
|
||||||
|
min-width: 0;
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,93 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { darkTheme, NButton, NConfigProvider } from 'naive-ui'
|
||||||
|
import { nextTick, ref } from 'vue'
|
||||||
|
|
||||||
|
import ComponentDesignCatalog from '@/components/ComponentDesignCatalog.vue'
|
||||||
|
import GameDesignPreview from '@/components/GameDesignPreview.vue'
|
||||||
|
import SiteDesignPreview from '@/components/SiteDesignPreview.vue'
|
||||||
|
const tabs = [
|
||||||
|
{ name: 'site', label: 'Сайт' },
|
||||||
|
{ name: 'game', label: 'Игра' },
|
||||||
|
{ name: 'components', label: 'Компоненты' },
|
||||||
|
] as const
|
||||||
|
const activeTab = ref<(typeof tabs)[number]['name']>('site')
|
||||||
|
const tabList = ref<HTMLElement | null>(null)
|
||||||
|
async function handleTabKey(event: KeyboardEvent) {
|
||||||
|
if (!['ArrowLeft', 'ArrowRight', 'Home', 'End'].includes(event.key)) return
|
||||||
|
event.preventDefault()
|
||||||
|
const index = tabs.findIndex((tab) => tab.name === activeTab.value)
|
||||||
|
const nextIndex =
|
||||||
|
event.key === 'Home'
|
||||||
|
? 0
|
||||||
|
: event.key === 'End'
|
||||||
|
? tabs.length - 1
|
||||||
|
: (index + (event.key === 'ArrowRight' ? 1 : -1) + tabs.length) % tabs.length
|
||||||
|
activeTab.value = tabs[nextIndex]!.name
|
||||||
|
await nextTick()
|
||||||
|
tabList.value?.querySelector<HTMLButtonElement>('[aria-selected="true"]')?.focus()
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<n-config-provider :theme="darkTheme">
|
||||||
|
<header class="design-header">Вечерний детектив · Дизайн-система</header>
|
||||||
|
<main class="center-block-custom design-scroll">
|
||||||
|
<div
|
||||||
|
ref="tabList"
|
||||||
|
class="design-tabs"
|
||||||
|
role="tablist"
|
||||||
|
aria-label="Стили оформления"
|
||||||
|
@keydown="handleTabKey"
|
||||||
|
>
|
||||||
|
<n-button
|
||||||
|
v-for="tab in tabs"
|
||||||
|
:id="`tab-${tab.name}`"
|
||||||
|
:key="tab.name"
|
||||||
|
role="tab"
|
||||||
|
:aria-selected="activeTab === tab.name"
|
||||||
|
:aria-controls="`panel-${tab.name}`"
|
||||||
|
:tabindex="activeTab === tab.name ? 0 : -1"
|
||||||
|
:type="activeTab === tab.name ? 'primary' : 'default'"
|
||||||
|
@click="activeTab = tab.name"
|
||||||
|
>{{ tab.label }}</n-button
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
:id="`panel-${activeTab}`"
|
||||||
|
role="tabpanel"
|
||||||
|
:aria-labelledby="`tab-${activeTab}`"
|
||||||
|
tabindex="0"
|
||||||
|
>
|
||||||
|
<SiteDesignPreview v-if="activeTab === 'site'" />
|
||||||
|
<GameDesignPreview v-else-if="activeTab === 'game'" />
|
||||||
|
<ComponentDesignCatalog v-else />
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
</n-config-provider>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.design-header {
|
||||||
|
align-items: center;
|
||||||
|
background: var(--brand-background);
|
||||||
|
color: var(--brand-text);
|
||||||
|
display: flex;
|
||||||
|
font-size: 16px;
|
||||||
|
height: 70px;
|
||||||
|
left: 0;
|
||||||
|
padding: 0 16px;
|
||||||
|
position: fixed;
|
||||||
|
right: 0;
|
||||||
|
top: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.design-scroll {
|
||||||
|
background: var(--brand-background);
|
||||||
|
}
|
||||||
|
|
||||||
|
.design-tabs {
|
||||||
|
display: flex;
|
||||||
|
gap: 12px;
|
||||||
|
padding: 16px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
defineProps<{ label: string }>()
|
||||||
|
</script>
|
||||||
|
<template>
|
||||||
|
<footer>
|
||||||
|
<p class="archive-number">{{ label }}</p>
|
||||||
|
<div><slot /></div>
|
||||||
|
</footer>
|
||||||
|
</template>
|
||||||
|
<style scoped>
|
||||||
|
footer {
|
||||||
|
border-top: 1px solid var(--brand-border);
|
||||||
|
display: grid;
|
||||||
|
gap: 12px;
|
||||||
|
padding-top: 32px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.archive-number {
|
||||||
|
color: var(--brand-text-muted);
|
||||||
|
font-size: 14px;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,160 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { darkTheme, NAlert, NConfigProvider } from 'naive-ui'
|
||||||
|
import { ref } from 'vue'
|
||||||
|
|
||||||
|
import BeltBlock from '@/components/BeltBlock.vue'
|
||||||
|
import DesignBrand from '@/components/DesignBrand.vue'
|
||||||
|
import GameInputForm from '@/components/GameInputForm.vue'
|
||||||
|
import HeaderText from '@/components/HeaderText.vue'
|
||||||
|
import IntroBlock from '@/components/IntroBlock.vue'
|
||||||
|
import MessagePaper from '@/components/MessagePaper.vue'
|
||||||
|
import MetalPlate from '@/components/MetalPlate.vue'
|
||||||
|
|
||||||
|
const lastCode = ref('')
|
||||||
|
async function demoAction(code: string): Promise<boolean> {
|
||||||
|
lastCode.value = code
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<n-config-provider :theme="darkTheme" :theme-overrides="{}">
|
||||||
|
<div class="game-preview width700">
|
||||||
|
<div class="game-brand">
|
||||||
|
<DesignBrand />
|
||||||
|
</div>
|
||||||
|
<h1 class="game-title">Материалы дела</h1>
|
||||||
|
<p class="description">
|
||||||
|
Игровой стиль: существующие бумажные листы, машинописный текст, кожа и металл. Все данные
|
||||||
|
демонстрационные.
|
||||||
|
</p>
|
||||||
|
<IntroBlock
|
||||||
|
:introduction="{
|
||||||
|
audio: '',
|
||||||
|
text: 'Последний поезд ушёл в полночь. На пустой платформе осталось письмо без подписи. Выясните, кому оно предназначалось и почему адресат не пришёл.',
|
||||||
|
}"
|
||||||
|
mode="show"
|
||||||
|
/>
|
||||||
|
<div class="paper-sample">
|
||||||
|
<MessagePaper>
|
||||||
|
<p class="document-number">Улика № 03 · Личное письмо</p>
|
||||||
|
<h2>Встретимся до полуночи</h2>
|
||||||
|
<hr />
|
||||||
|
<p>
|
||||||
|
Если поезд придёт вовремя, я буду ждать у старых часов. Возьми письмо с собой. Кажется,
|
||||||
|
мы упустили одну важную деталь.
|
||||||
|
</p>
|
||||||
|
<p>Не доверяй времени на билете.</p>
|
||||||
|
</MessagePaper>
|
||||||
|
</div>
|
||||||
|
<BeltBlock class="material-belt"
|
||||||
|
><div class="material-label"><HeaderText>Панель расследования</HeaderText></div></BeltBlock
|
||||||
|
>
|
||||||
|
<div class="metal-sample">
|
||||||
|
<MetalPlate><div class="metal-label">Металлическая деталь панели</div></MetalPlate>
|
||||||
|
</div>
|
||||||
|
<p class="description">
|
||||||
|
Введите место назначения: действие покажет локальный результат и очистит поле, как при
|
||||||
|
успешном ходе в игре.
|
||||||
|
</p>
|
||||||
|
<div class="game-controls">
|
||||||
|
<GameInputForm
|
||||||
|
:add-action="demoAction"
|
||||||
|
:all-places-count="3"
|
||||||
|
:new-places-count="1"
|
||||||
|
game-status="run"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<n-alert v-if="lastCode" type="success" class="action-result" role="status"
|
||||||
|
>Ход «{{ lastCode }}» принят локально. На сервер ничего не отправлено.</n-alert
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
</n-config-provider>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.game-preview {
|
||||||
|
background: var(--brand-background);
|
||||||
|
color: var(--brand-text);
|
||||||
|
max-width: 100%;
|
||||||
|
padding: 32px 24px 48px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.game-brand {
|
||||||
|
align-items: center;
|
||||||
|
display: flex;
|
||||||
|
gap: 16px;
|
||||||
|
margin-bottom: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.game-title {
|
||||||
|
color: var(--brand-text);
|
||||||
|
font-family: 'font_old_typer', serif;
|
||||||
|
font-size: 24px;
|
||||||
|
line-height: 1.3;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.description {
|
||||||
|
color: var(--brand-text-muted);
|
||||||
|
margin: 24px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.paper-sample {
|
||||||
|
margin: 32px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.paper-sample h2 {
|
||||||
|
font-size: 24px;
|
||||||
|
line-height: 1.3;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.paper-sample hr {
|
||||||
|
border: 0;
|
||||||
|
border-top: 1px dashed var(--brand-background);
|
||||||
|
margin: 16px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.paper-sample p {
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.document-number {
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.material-belt {
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.material-label {
|
||||||
|
background: rgb(0 0 0 / 80%);
|
||||||
|
padding: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.metal-sample {
|
||||||
|
margin: 24px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.metal-label {
|
||||||
|
background: rgb(0 0 0 / 80%);
|
||||||
|
color: var(--brand-bronze);
|
||||||
|
padding: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.game-controls {
|
||||||
|
margin-inline: 6px;
|
||||||
|
margin-top: 48px;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* stylelint-disable-next-line selector-pseudo-class-no-unknown -- В образцах игровая панель находится в потоке страницы. */
|
||||||
|
.game-controls :deep(.form-custom) {
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.action-result {
|
||||||
|
margin-top: 24px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,72 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { KeyOutline, MailOutline, TicketOutline } from '@vicons/ionicons5'
|
||||||
|
import { Icon } from '@vicons/utils'
|
||||||
|
|
||||||
|
import StatusStamp from '@/components/StatusStamp.vue'
|
||||||
|
defineProps<{
|
||||||
|
object: 'ticket' | 'key' | 'letter'
|
||||||
|
label: string
|
||||||
|
caption: string
|
||||||
|
status?: string
|
||||||
|
}>()
|
||||||
|
const icons = { ticket: TicketOutline, key: KeyOutline, letter: MailOutline }
|
||||||
|
</script>
|
||||||
|
<template>
|
||||||
|
<div class="object-cover" :class="object">
|
||||||
|
<span class="cover-label">{{ label }}</span>
|
||||||
|
<Icon :size="72" aria-hidden="true"><component :is="icons[object]" /></Icon>
|
||||||
|
<span class="object-caption">{{ caption }}</span>
|
||||||
|
<StatusStamp v-if="status" :text="status" />
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<style scoped>
|
||||||
|
.object-cover {
|
||||||
|
background: var(--brand-paper) url('@/assets/images/paper_white.jpg') center / cover;
|
||||||
|
border: 1px solid var(--brand-border);
|
||||||
|
color: var(--brand-background);
|
||||||
|
display: grid;
|
||||||
|
gap: 16px;
|
||||||
|
min-height: 240px;
|
||||||
|
padding: 20px 12px;
|
||||||
|
place-items: center center;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.object-cover::before,
|
||||||
|
.object-cover::after {
|
||||||
|
border: 1px solid var(--brand-background);
|
||||||
|
content: '';
|
||||||
|
height: 12px;
|
||||||
|
pointer-events: none;
|
||||||
|
position: absolute;
|
||||||
|
width: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.object-cover::before {
|
||||||
|
border-bottom: 0;
|
||||||
|
border-right: 0;
|
||||||
|
left: 8px;
|
||||||
|
top: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.object-cover::after {
|
||||||
|
border-left: 0;
|
||||||
|
border-top: 0;
|
||||||
|
bottom: 8px;
|
||||||
|
right: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ticket {
|
||||||
|
border-bottom: 2px dashed var(--brand-background);
|
||||||
|
}
|
||||||
|
|
||||||
|
.letter {
|
||||||
|
background-image: url('@/assets/images/paper.jpg');
|
||||||
|
}
|
||||||
|
|
||||||
|
.cover-label,
|
||||||
|
.object-caption {
|
||||||
|
font-size: 14px;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
defineProps<{ id?: string; eyebrow?: string; title: string }>()
|
||||||
|
</script>
|
||||||
|
<template>
|
||||||
|
<div class="section-heading">
|
||||||
|
<p v-if="eyebrow" class="eyebrow">{{ eyebrow }}</p>
|
||||||
|
<h2 :id="id">
|
||||||
|
<slot>{{ title }}</slot>
|
||||||
|
</h2>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<style scoped>
|
||||||
|
.section-heading {
|
||||||
|
display: grid;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.eyebrow {
|
||||||
|
color: var(--brand-accent);
|
||||||
|
font-size: 14px;
|
||||||
|
letter-spacing: 0.1em;
|
||||||
|
margin: 0;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
|
||||||
|
h2 {
|
||||||
|
font-size: 28px;
|
||||||
|
font-weight: 600;
|
||||||
|
line-height: 1.3;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,647 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import {
|
||||||
|
NAlert,
|
||||||
|
NButton,
|
||||||
|
NCard,
|
||||||
|
NCheckbox,
|
||||||
|
NDataTable,
|
||||||
|
NFormItem,
|
||||||
|
NInput,
|
||||||
|
NModal,
|
||||||
|
NSelect,
|
||||||
|
NTag,
|
||||||
|
} from 'naive-ui'
|
||||||
|
import { computed, ref } from 'vue'
|
||||||
|
|
||||||
|
import BeltBlock from '@/components/BeltBlock.vue'
|
||||||
|
import CaseBoard from '@/components/CaseBoard.vue'
|
||||||
|
import CaseFolder from '@/components/CaseFolder.vue'
|
||||||
|
import DesignBrand from '@/components/DesignBrand.vue'
|
||||||
|
import DossierFooter from '@/components/DossierFooter.vue'
|
||||||
|
import SectionHeading from '@/components/SectionHeading.vue'
|
||||||
|
import SiteThemeProvider from '@/components/SiteThemeProvider.vue'
|
||||||
|
import TextHighlight from '@/components/TextHighlight.vue'
|
||||||
|
|
||||||
|
const palette = [
|
||||||
|
['background', 'Фон'],
|
||||||
|
['surface', 'Панель'],
|
||||||
|
['text', 'Текст'],
|
||||||
|
['text-muted', 'Подпись'],
|
||||||
|
['accent', 'Жёлтый акцент'],
|
||||||
|
['paper', 'Бумага'],
|
||||||
|
] as const
|
||||||
|
|
||||||
|
const isModalOpen = ref(false)
|
||||||
|
const selectedTitle = ref('Последний поезд')
|
||||||
|
function openCase(title: string) {
|
||||||
|
selectedTitle.value = title
|
||||||
|
isModalOpen.value = true
|
||||||
|
}
|
||||||
|
const teamName = ref('')
|
||||||
|
const difficulty = ref('medium')
|
||||||
|
const hasMaterials = ref(false)
|
||||||
|
const isSubmitted = ref(false)
|
||||||
|
const isSaved = ref(false)
|
||||||
|
const nameError = computed(() => isSubmitted.value && !teamName.value.trim())
|
||||||
|
const difficulties = [
|
||||||
|
{ label: 'Лёгкое дело', value: 'easy' },
|
||||||
|
{ label: 'Средняя сложность', value: 'medium' },
|
||||||
|
{ label: 'Для опытных детективов', value: 'hard' },
|
||||||
|
]
|
||||||
|
function submitDemo() {
|
||||||
|
isSubmitted.value = true
|
||||||
|
isSaved.value = !nameError.value
|
||||||
|
}
|
||||||
|
const columns = [
|
||||||
|
{ title: 'Команда', key: 'name' },
|
||||||
|
{ title: 'Улики', key: 'clues' },
|
||||||
|
{ title: 'Статус', key: 'status' },
|
||||||
|
]
|
||||||
|
const teams = [
|
||||||
|
{ key: 1, name: 'Полночь', clues: '7 / 9', status: 'Расследуют' },
|
||||||
|
{ key: 2, name: 'Третий звонок', clues: '9 / 9', status: 'Дело раскрыто' },
|
||||||
|
]
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="site-preview">
|
||||||
|
<SiteThemeProvider>
|
||||||
|
<header class="showcase-header">
|
||||||
|
<BeltBlock class="header-belt">
|
||||||
|
<div class="header-content">
|
||||||
|
<a class="brand" href="#preview">
|
||||||
|
<DesignBrand />
|
||||||
|
</a>
|
||||||
|
<nav aria-label="Разделы образцов">
|
||||||
|
<a href="#foundation">Стиль</a>
|
||||||
|
<a href="#controls">Элементы</a>
|
||||||
|
<a href="#paper">Материалы</a>
|
||||||
|
</nav>
|
||||||
|
</div>
|
||||||
|
</BeltBlock>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div class="showcase-scroll">
|
||||||
|
<main class="width1200 showcase-content">
|
||||||
|
<p class="eyebrow">Дизайн-система · Золотой вечер</p>
|
||||||
|
<section id="preview" class="hero" aria-labelledby="hero-title">
|
||||||
|
<div class="hero-copy">
|
||||||
|
<p class="eyebrow">Расследование своей компанией</p>
|
||||||
|
|
||||||
|
<h1 id="hero-title">
|
||||||
|
Соберите друзей.<br /><TextHighlight>Раскройте дело.</TextHighlight>
|
||||||
|
</h1>
|
||||||
|
<p class="lead">
|
||||||
|
Один вечер, загадочная история и улики, которые складываются в разгадку. Главное —
|
||||||
|
то, что вы заметите вместе.
|
||||||
|
</p>
|
||||||
|
<div class="row">
|
||||||
|
<n-button
|
||||||
|
class="accent-button"
|
||||||
|
type="primary"
|
||||||
|
size="large"
|
||||||
|
@click="openCase('Последний поезд')"
|
||||||
|
>Выбрать дело</n-button
|
||||||
|
>
|
||||||
|
<n-button class="paper-link" tag="a" href="#paper" size="large" secondary
|
||||||
|
>Посмотреть улику</n-button
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
<p class="muted">
|
||||||
|
Образцы будущего сайта. Все данные на этой странице — демонстрационные.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<n-card class="case-card" :content-style="{ display: 'grid', gap: '16px' }">
|
||||||
|
<CaseBoard
|
||||||
|
label="Дело 014 / Связь установлена"
|
||||||
|
aria-label="Улики дела «Последний поезд»"
|
||||||
|
footer="Три детали. Одна история."
|
||||||
|
:fragments="[
|
||||||
|
{
|
||||||
|
number: '01',
|
||||||
|
title: 'Записка',
|
||||||
|
text: '«Буду ждать у старых часов. Не доверяй времени на билете».',
|
||||||
|
caption: 'Подпись отсутствует',
|
||||||
|
kind: 'note',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
number: '02',
|
||||||
|
title: 'Билет',
|
||||||
|
text: 'ВОКЗАЛ → ПОЛНОЧЬ',
|
||||||
|
detail: '23:40',
|
||||||
|
caption: 'Вагон 03 · Место 14',
|
||||||
|
kind: 'ticket',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
number: '03',
|
||||||
|
title: 'Отправление',
|
||||||
|
text: '00:00',
|
||||||
|
caption: 'Разница — двадцать минут. Совпадение?',
|
||||||
|
kind: 'timetable',
|
||||||
|
},
|
||||||
|
]"
|
||||||
|
/>
|
||||||
|
<aside class="margin-note">
|
||||||
|
На полях: обратите внимание на время. У каждого своя версия.
|
||||||
|
</aside>
|
||||||
|
<h2 class="case-title">Последний поезд</h2>
|
||||||
|
|
||||||
|
<p>Письмо без подписи, исчезнувший пассажир и встреча, которая не состоялась.</p>
|
||||||
|
<p class="document-number">2–6 игроков · 90 минут</p>
|
||||||
|
<n-button
|
||||||
|
class="accent-button case-action"
|
||||||
|
type="primary"
|
||||||
|
block
|
||||||
|
@click="openCase('Последний поезд')"
|
||||||
|
>Открыть дело</n-button
|
||||||
|
>
|
||||||
|
</n-card>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section id="foundation" aria-labelledby="foundation-title">
|
||||||
|
<SectionHeading
|
||||||
|
id="foundation-title"
|
||||||
|
eyebrow="01 / Основы"
|
||||||
|
title="Цвет и типографика"
|
||||||
|
/>
|
||||||
|
<div class="palette">
|
||||||
|
<div v-for="[token, label] in palette" :key="token" class="swatch-card">
|
||||||
|
<div class="swatch" :style="{ backgroundColor: `var(--brand-${token})` }"></div>
|
||||||
|
<strong>{{ label }}</strong
|
||||||
|
><code>--brand-{{ token }}</code>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="two-columns typography">
|
||||||
|
<div>
|
||||||
|
<h3>Истина складывается из деталей</h3>
|
||||||
|
<p>
|
||||||
|
Ровные тёмные панели, читаемый интерфейсный текст и ярко-жёлтое главное действие.
|
||||||
|
</p>
|
||||||
|
<p class="muted">Второстепенный текст: время игры, автор и пояснения.</p>
|
||||||
|
<a href="#controls">Пример текстовой ссылки</a>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h3>Лёгкие фактуры</h3>
|
||||||
|
<p>
|
||||||
|
Кожаная полоса в шапке и фрагменты улик связывают сайт с игрой. Доска
|
||||||
|
расследования украшает превью дела; формы и таблицы остаются ровными панелями.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section id="controls" aria-labelledby="controls-title">
|
||||||
|
<SectionHeading
|
||||||
|
id="controls-title"
|
||||||
|
eyebrow="02 / Управление"
|
||||||
|
title="Кнопки и состояния"
|
||||||
|
/>
|
||||||
|
<div class="row">
|
||||||
|
<n-button class="accent-button" type="primary" @click="openCase('Последний поезд')"
|
||||||
|
>Основное действие</n-button
|
||||||
|
>
|
||||||
|
<n-button secondary @click="openCase('Последний поезд')">Второстепенное</n-button>
|
||||||
|
<n-button type="error" @click="openCase('Последний поезд')"
|
||||||
|
>Опасное действие</n-button
|
||||||
|
>
|
||||||
|
<n-button disabled>Недоступно</n-button><n-button loading>Загрузка</n-button>
|
||||||
|
</div>
|
||||||
|
<div class="row">
|
||||||
|
<n-tag type="success">Раскрыто</n-tag><n-tag type="warning">В работе</n-tag
|
||||||
|
><n-tag type="error">Ошибка</n-tag><n-tag type="info">Подсказка</n-tag>
|
||||||
|
</div>
|
||||||
|
<div class="two-columns">
|
||||||
|
<n-card title="Соберите команду">
|
||||||
|
<form novalidate @submit.prevent="submitDemo">
|
||||||
|
<n-form-item
|
||||||
|
label="Название команды"
|
||||||
|
label-for="demo-team"
|
||||||
|
:validation-status="nameError ? 'error' : undefined"
|
||||||
|
:feedback="nameError ? 'Укажите название команды' : undefined"
|
||||||
|
>
|
||||||
|
<n-input
|
||||||
|
v-model:value="teamName"
|
||||||
|
:input-props="{ id: 'demo-team', required: true }"
|
||||||
|
placeholder="Например, Полночь"
|
||||||
|
@update:value="isSaved = false"
|
||||||
|
/>
|
||||||
|
</n-form-item>
|
||||||
|
<n-form-item label="Сложность дела" label-for="demo-difficulty"
|
||||||
|
><n-select
|
||||||
|
v-model:value="difficulty"
|
||||||
|
:options="difficulties"
|
||||||
|
:input-props="{ id: 'demo-difficulty' }"
|
||||||
|
/></n-form-item>
|
||||||
|
<n-checkbox v-model:checked="hasMaterials"
|
||||||
|
>Бумажные материалы подготовлены</n-checkbox
|
||||||
|
>
|
||||||
|
<div class="row">
|
||||||
|
<n-button attr-type="submit" type="primary">Проверить форму</n-button>
|
||||||
|
</div>
|
||||||
|
<n-alert v-if="isSaved" type="success"
|
||||||
|
>Команда «{{ teamName.trim() }}» готова. Это локальный пример, данные не
|
||||||
|
отправляются.</n-alert
|
||||||
|
>
|
||||||
|
</form>
|
||||||
|
</n-card>
|
||||||
|
<div class="notifications">
|
||||||
|
<n-alert type="info" title="Новая улика"
|
||||||
|
>В материалах дела появилось письмо.</n-alert
|
||||||
|
>
|
||||||
|
<n-alert type="warning" title="Обратите внимание"
|
||||||
|
>Перед началом подготовьте бумажные материалы.</n-alert
|
||||||
|
>
|
||||||
|
<n-alert type="error" title="Не удалось загрузить материалы"
|
||||||
|
>Попробуйте ещё раз.</n-alert
|
||||||
|
>
|
||||||
|
<n-alert type="success" title="Дело раскрыто"
|
||||||
|
>Все факты сложились в одну историю.</n-alert
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section aria-labelledby="table-title">
|
||||||
|
<SectionHeading id="table-title" eyebrow="03 / Данные" title="Ход расследования" />
|
||||||
|
<n-data-table :columns="columns" :data="teams" :bordered="false" :scroll-x="480" />
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section id="paper" aria-labelledby="paper-title">
|
||||||
|
<SectionHeading
|
||||||
|
id="paper-title"
|
||||||
|
eyebrow="04 / Каталог дел"
|
||||||
|
title="У каждой истории свои улики"
|
||||||
|
/>
|
||||||
|
<p class="muted">
|
||||||
|
Три демонстрационные папки: билет, ключ и письмо. Штампы показывают примеры состояний.
|
||||||
|
</p>
|
||||||
|
<div class="folder-grid">
|
||||||
|
<CaseFolder
|
||||||
|
number="014"
|
||||||
|
cover-label="Материал 014 / Билет"
|
||||||
|
cover-caption="23:40 · Вагон 03"
|
||||||
|
title="Последний поезд"
|
||||||
|
description="Письмо, билет и двадцать минут, которые меняют всё."
|
||||||
|
quote="Часы остановились раньше, чем исчез пассажир."
|
||||||
|
object="ticket"
|
||||||
|
status="Новое дело"
|
||||||
|
@open="openCase"
|
||||||
|
/>
|
||||||
|
<CaseFolder
|
||||||
|
number="007"
|
||||||
|
cover-label="Материал 007 / Ключ"
|
||||||
|
cover-caption="Комната 07"
|
||||||
|
disabled
|
||||||
|
action-label="Скоро в каталоге"
|
||||||
|
title="Седьмая комната"
|
||||||
|
description="Ключ найден. Но кто запер дверь с другой стороны?"
|
||||||
|
quote="Этот ключ открывает дверь, которой нет на плане."
|
||||||
|
object="key"
|
||||||
|
status="Скоро"
|
||||||
|
@open="openCase"
|
||||||
|
/>
|
||||||
|
<CaseFolder
|
||||||
|
number="021"
|
||||||
|
cover-label="Материал 021 / Письмо"
|
||||||
|
cover-caption="Адресат неизвестен"
|
||||||
|
title="Без подписи"
|
||||||
|
description="Три письма. Один адрес. Ни одного имени."
|
||||||
|
quote="Последняя строка написана другим почерком."
|
||||||
|
object="letter"
|
||||||
|
status="Пройдено"
|
||||||
|
@open="openCase"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
<DossierFooter label="Лист 01 / Вечерний детектив"
|
||||||
|
>Продолжение — в следующем деле.</DossierFooter
|
||||||
|
>
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<n-modal v-model:show="isModalOpen">
|
||||||
|
<n-card
|
||||||
|
class="demo-modal"
|
||||||
|
:content-style="{ display: 'grid', gap: '16px' }"
|
||||||
|
:title="selectedTitle"
|
||||||
|
role="dialog"
|
||||||
|
aria-modal="true"
|
||||||
|
:aria-label="selectedTitle"
|
||||||
|
closable
|
||||||
|
@close="isModalOpen = false"
|
||||||
|
>
|
||||||
|
<p>Соберите компанию, подготовьте материалы и начните с первой улики.</p>
|
||||||
|
<p class="muted">Это демонстрация оформления окна. Игровое действие не выполняется.</p>
|
||||||
|
<template #footer
|
||||||
|
><n-button type="primary" @click="isModalOpen = false">Понятно</n-button></template
|
||||||
|
>
|
||||||
|
</n-card>
|
||||||
|
</n-modal>
|
||||||
|
</SiteThemeProvider>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.header-belt {
|
||||||
|
height: 100%;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-content {
|
||||||
|
align-items: center;
|
||||||
|
background: rgb(0 0 0 / 80%);
|
||||||
|
display: flex;
|
||||||
|
gap: 16px;
|
||||||
|
justify-content: space-between;
|
||||||
|
min-height: 70px;
|
||||||
|
padding: 8px 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.paper-link {
|
||||||
|
color: var(--brand-text);
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
|
||||||
|
.paper-link:hover {
|
||||||
|
color: var(--brand-accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.accent-button:focus-visible {
|
||||||
|
outline: 2px solid var(--brand-accent);
|
||||||
|
outline-offset: 3px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.site-preview {
|
||||||
|
--site-card-padding: 16px;
|
||||||
|
|
||||||
|
background: var(--brand-background);
|
||||||
|
color: var(--brand-text);
|
||||||
|
font-family: 'font_old_typer', serif;
|
||||||
|
font-size: 18px;
|
||||||
|
line-height: 1.5;
|
||||||
|
min-height: 100dvh;
|
||||||
|
}
|
||||||
|
|
||||||
|
.showcase-header {
|
||||||
|
background: var(--brand-background);
|
||||||
|
border-bottom: 1px solid var(--brand-border);
|
||||||
|
min-height: 70px;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.brand {
|
||||||
|
align-items: center;
|
||||||
|
color: var(--brand-text);
|
||||||
|
display: flex;
|
||||||
|
font-size: 16px;
|
||||||
|
font-weight: 650;
|
||||||
|
gap: 12px;
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
nav {
|
||||||
|
display: none;
|
||||||
|
gap: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
a {
|
||||||
|
color: var(--brand-accent);
|
||||||
|
text-underline-offset: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
a:hover {
|
||||||
|
color: var(--brand-accent-hover);
|
||||||
|
}
|
||||||
|
|
||||||
|
a:focus-visible {
|
||||||
|
outline: 2px solid var(--brand-accent);
|
||||||
|
outline-offset: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.showcase-scroll {
|
||||||
|
background: var(--brand-background);
|
||||||
|
scroll-behavior: smooth;
|
||||||
|
}
|
||||||
|
|
||||||
|
.showcase-content {
|
||||||
|
max-width: 100%;
|
||||||
|
padding: 24px 16px 32px;
|
||||||
|
}
|
||||||
|
|
||||||
|
section {
|
||||||
|
border-bottom: 1px dashed var(--brand-border);
|
||||||
|
display: grid;
|
||||||
|
gap: 24px;
|
||||||
|
grid-template-columns: minmax(0, 1fr);
|
||||||
|
margin-top: 32px;
|
||||||
|
min-width: 0;
|
||||||
|
padding-bottom: 32px;
|
||||||
|
scroll-margin-top: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hero,
|
||||||
|
.two-columns {
|
||||||
|
display: grid;
|
||||||
|
gap: 32px;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
h1 {
|
||||||
|
font-size: clamp(32px, 4vw, 48px);
|
||||||
|
font-weight: 600;
|
||||||
|
letter-spacing: normal;
|
||||||
|
line-height: 1.15;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.margin-note {
|
||||||
|
border-left: 1px solid var(--brand-bronze);
|
||||||
|
color: var(--brand-text-muted);
|
||||||
|
font-size: 14px;
|
||||||
|
padding-left: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.folder-grid {
|
||||||
|
display: grid;
|
||||||
|
gap: 32px;
|
||||||
|
grid-template-columns: minmax(0, 1fr);
|
||||||
|
}
|
||||||
|
|
||||||
|
.archive-number {
|
||||||
|
color: var(--brand-text-muted);
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
h2 {
|
||||||
|
font-size: 28px;
|
||||||
|
font-weight: 600;
|
||||||
|
line-height: 1.3;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
h3 {
|
||||||
|
font-size: 22px;
|
||||||
|
font-weight: 600;
|
||||||
|
line-height: 1.4;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
p {
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lead {
|
||||||
|
color: var(--brand-text-muted);
|
||||||
|
font-size: 18px;
|
||||||
|
line-height: 1.5;
|
||||||
|
max-width: 580px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.muted {
|
||||||
|
color: var(--brand-text-muted);
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.eyebrow {
|
||||||
|
color: var(--brand-accent);
|
||||||
|
font-size: 14px;
|
||||||
|
letter-spacing: 0.1em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
|
||||||
|
.row {
|
||||||
|
align-items: center;
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.case-card {
|
||||||
|
align-self: start;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* stylelint-disable-next-line selector-pseudo-class-no-unknown -- Vue scoped: связь в дочерней доске при фокусе действия карточки. */
|
||||||
|
.case-card:focus-within :deep(.clues)::before {
|
||||||
|
background: var(--brand-accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.case-title {
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.case-action {
|
||||||
|
margin-top: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.palette {
|
||||||
|
display: grid;
|
||||||
|
gap: 12px;
|
||||||
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
|
}
|
||||||
|
|
||||||
|
.swatch-card {
|
||||||
|
background: var(--brand-surface);
|
||||||
|
border: 1px solid var(--brand-border);
|
||||||
|
border-radius: 8px;
|
||||||
|
overflow: hidden;
|
||||||
|
padding-bottom: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.swatch {
|
||||||
|
border-bottom: 1px solid var(--brand-border);
|
||||||
|
height: 72px;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.swatch-card strong,
|
||||||
|
.swatch-card code {
|
||||||
|
display: block;
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
padding: 0 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.swatch-card code {
|
||||||
|
color: var(--brand-text-muted);
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hero-copy,
|
||||||
|
.typography > div {
|
||||||
|
align-content: start;
|
||||||
|
display: grid;
|
||||||
|
gap: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hero-copy .row {
|
||||||
|
margin-top: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
form .row {
|
||||||
|
margin: 24px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.notifications {
|
||||||
|
display: grid;
|
||||||
|
gap: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.document-number {
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.demo-modal {
|
||||||
|
font-family: 'font_old_typer', serif;
|
||||||
|
font-size: 18px;
|
||||||
|
line-height: 1.5;
|
||||||
|
max-width: 560px;
|
||||||
|
width: calc(100vw - 32px);
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (width >= 1024px) {
|
||||||
|
nav {
|
||||||
|
display: flex;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-content {
|
||||||
|
padding: 8px 32px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.site-preview {
|
||||||
|
--site-card-padding: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.showcase-content {
|
||||||
|
padding: 32px 24px 48px;
|
||||||
|
}
|
||||||
|
|
||||||
|
section {
|
||||||
|
margin-top: 48px;
|
||||||
|
padding-bottom: 48px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hero {
|
||||||
|
align-items: start;
|
||||||
|
gap: 32px;
|
||||||
|
grid-template-columns: 1.4fr 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
.folder-grid {
|
||||||
|
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||||
|
}
|
||||||
|
|
||||||
|
.two-columns {
|
||||||
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
|
}
|
||||||
|
|
||||||
|
.palette {
|
||||||
|
grid-template-columns: repeat(6, minmax(0, 1fr));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (prefers-reduced-motion: reduce) {
|
||||||
|
.showcase-scroll {
|
||||||
|
scroll-behavior: auto;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,72 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import type { GlobalThemeOverrides } from 'naive-ui'
|
||||||
|
import { darkTheme, NConfigProvider } from 'naive-ui'
|
||||||
|
import { onMounted, ref } from 'vue'
|
||||||
|
|
||||||
|
const root = ref<HTMLElement | null>(null)
|
||||||
|
const theme = ref<GlobalThemeOverrides>({})
|
||||||
|
onMounted(() => {
|
||||||
|
if (!root.value) return
|
||||||
|
const styles = getComputedStyle(root.value)
|
||||||
|
const color = (name: string) => styles.getPropertyValue(`--brand-${name}`).trim()
|
||||||
|
theme.value = {
|
||||||
|
common: {
|
||||||
|
fontFamily: styles.fontFamily,
|
||||||
|
fontSize: '18px',
|
||||||
|
fontSizeSmall: '14px',
|
||||||
|
fontSizeMedium: '18px',
|
||||||
|
fontSizeLarge: '20px',
|
||||||
|
lineHeight: '1.5',
|
||||||
|
bodyColor: color('background'),
|
||||||
|
cardColor: color('surface'),
|
||||||
|
modalColor: color('surface'),
|
||||||
|
popoverColor: color('surface'),
|
||||||
|
inputColor: color('background'),
|
||||||
|
tableColor: color('surface'),
|
||||||
|
textColorBase: color('text'),
|
||||||
|
textColor1: color('text'),
|
||||||
|
textColor2: color('text'),
|
||||||
|
textColor3: color('text-muted'),
|
||||||
|
placeholderColor: color('text-muted'),
|
||||||
|
borderColor: color('border'),
|
||||||
|
primaryColor: color('accent'),
|
||||||
|
primaryColorHover: color('accent-hover'),
|
||||||
|
primaryColorPressed: color('accent-pressed'),
|
||||||
|
primaryColorSuppl: color('accent'),
|
||||||
|
successColor: color('success'),
|
||||||
|
infoColor: color('info'),
|
||||||
|
warningColor: color('accent'),
|
||||||
|
errorColor: color('error'),
|
||||||
|
borderRadius: '8px',
|
||||||
|
},
|
||||||
|
Card: {
|
||||||
|
paddingSmall: 'var(--site-card-padding, 16px)',
|
||||||
|
paddingMedium: 'var(--site-card-padding, 16px)',
|
||||||
|
paddingLarge: 'var(--site-card-padding, 24px)',
|
||||||
|
},
|
||||||
|
Button: {
|
||||||
|
textColorPrimary: color('background'),
|
||||||
|
textColorHoverPrimary: color('background'),
|
||||||
|
textColorPressedPrimary: color('background'),
|
||||||
|
textColorFocusPrimary: color('background'),
|
||||||
|
textColorError: color('background'),
|
||||||
|
textColorHoverError: color('background'),
|
||||||
|
textColorPressedError: color('background'),
|
||||||
|
textColorFocusError: color('background'),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
<template>
|
||||||
|
<div ref="root" class="site-theme">
|
||||||
|
<n-config-provider :theme="darkTheme" :theme-overrides="theme"><slot /></n-config-provider>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<style scoped>
|
||||||
|
.site-theme {
|
||||||
|
color: var(--brand-text);
|
||||||
|
font-family: 'font_old_typer', serif;
|
||||||
|
font-size: 18px;
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
defineProps<{ text?: string }>()
|
||||||
|
</script>
|
||||||
|
<template>
|
||||||
|
<span class="status-stamp"
|
||||||
|
><slot>{{ text }}</slot></span
|
||||||
|
>
|
||||||
|
</template>
|
||||||
|
<style scoped>
|
||||||
|
.status-stamp {
|
||||||
|
border: 2px solid currentcolor;
|
||||||
|
color: var(--brand-background);
|
||||||
|
font-size: 14px;
|
||||||
|
padding: 4px 8px;
|
||||||
|
transform: rotate(-5deg);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
<template>
|
||||||
|
<span class="marker"><slot /></span>
|
||||||
|
</template>
|
||||||
|
<style scoped>
|
||||||
|
.marker {
|
||||||
|
color: var(--brand-background);
|
||||||
|
display: inline;
|
||||||
|
isolation: isolate;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.marker::before {
|
||||||
|
background: var(--brand-bronze);
|
||||||
|
clip-path: polygon(0 8%, 98% 0, 100% 88%, 2% 100%);
|
||||||
|
content: '';
|
||||||
|
inset: 0 -4px;
|
||||||
|
pointer-events: none;
|
||||||
|
position: absolute;
|
||||||
|
z-index: -1;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -3,6 +3,15 @@ import { createRouter, createWebHistory } from 'vue-router'
|
|||||||
const router = createRouter({
|
const router = createRouter({
|
||||||
history: createWebHistory(import.meta.env.BASE_URL),
|
history: createWebHistory(import.meta.env.BASE_URL),
|
||||||
routes: [
|
routes: [
|
||||||
|
...(import.meta.env.DEV
|
||||||
|
? [
|
||||||
|
{
|
||||||
|
path: '/design-system',
|
||||||
|
name: 'design-system',
|
||||||
|
component: () => import('../views/DesignSystemView.vue'),
|
||||||
|
},
|
||||||
|
]
|
||||||
|
: []),
|
||||||
{
|
{
|
||||||
path: '/participation',
|
path: '/participation',
|
||||||
name: 'participation',
|
name: 'participation',
|
||||||
|
|||||||
@@ -0,0 +1,7 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import DesignSystemPage from '@/components/DesignSystemPage.vue'
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<DesignSystemPage />
|
||||||
|
</template>
|
||||||
Reference in New Issue
Block a user