This commit is contained in:
2026-10-14 23:39:17 +07:00
parent 1218f1112c
commit 414513803c
18 changed files with 2285 additions and 2 deletions
+301
View File
@@ -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 локально и не отправляет запросы к серверу.
+4 -2
View File
@@ -125,8 +125,10 @@
`@media (width >= 1024px)`.
- Сохраняй текущий flow: `HeaderMenu` сверху, `.center-block-custom` для
прокрутки, `.width700` для дел и `.width1200` для списков.
- Бумажные листы используют `MessagePaper` и `font_old_typer`; UI-формы, меню и
кнопки этот шрифт не используют.
- Бумажные листы используют `MessagePaper` и `font_old_typer`. В стиле сайта
`font_old_typer` используется также для текста, меню, кнопок и форм согласно
`docs/DESIGN_SYSTEM.md` (выбор владельца от 14.10.2026). Игровые компоненты
сохраняют существующую типографику. Рабочие экраны мигрируют отдельно.
- Не добавляй глобальный utility-класс для одного компонента.
## 8. Формы, доступность и состояния
+15
View File
@@ -1,5 +1,20 @@
/* color palette from <https://github.com/vuejs/theme> */
: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-soft: #f8f8f8;
--vt-c-white-mute: #f2f2f2;
+179
View File
@@ -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>
+122
View File
@@ -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>
+480
View File
@@ -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>
+31
View File
@@ -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>
+93
View File
@@ -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>
+23
View File
@@ -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>
+160
View File
@@ -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>
+72
View File
@@ -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>
+32
View File
@@ -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>
+647
View File
@@ -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>
+72
View File
@@ -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>
+17
View File
@@ -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>
+21
View File
@@ -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>
+9
View File
@@ -3,6 +3,15 @@ import { createRouter, createWebHistory } from 'vue-router'
const router = createRouter({
history: createWebHistory(import.meta.env.BASE_URL),
routes: [
...(import.meta.env.DEV
? [
{
path: '/design-system',
name: 'design-system',
component: () => import('../views/DesignSystemView.vue'),
},
]
: []),
{
path: '/participation',
name: 'participation',
+7
View File
@@ -0,0 +1,7 @@
<script setup lang="ts">
import DesignSystemPage from '@/components/DesignSystemPage.vue'
</script>
<template>
<DesignSystemPage />
</template>