From 38b985a47380467cacd174a00a30cd33c638d9ea Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=94=D0=BC=D0=B8=D1=82=D1=80=D0=B8=D0=B9?= Date: Tue, 12 May 2026 09:09:58 +0300 Subject: [PATCH] =?UTF-8?q?docs(plans):=20portal=20redesign=20=E2=80=94=20?= =?UTF-8?q?Quiet=20Luxury=20Iteration=201=20=E2=80=94=2018-task=20TDD=20de?= =?UTF-8?q?composition?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Tasks 1-3 CSS foundation (tokens/typography/motion). Task 4 Vuetify theme + global defaults. Tasks 5-7 composables (useStatusPill/useCountUp/useDensity). Tasks 8-11 UI components (StatusPill/Kbd/FilterChip/DensityToggle) + Histoire stories. Task 12 AppSidebar redesign (двухтоновый shell + Cmd-K stub + active marker motion #7). Task 13 page transition wiring (motion #6). Tasks 14-17 view applications (Dashboard count-up #1, Deals filterbar + stagger #2 + hover lift #4, Kanban hover lift, Projects tokens). Task 18 acceptance verification + Pa11y CI sweep. Self-review: spec coverage complete (all 7 motion patterns wired; stagger #2 added в Task 3 utility + Task 15 application). 0 placeholders. Type consistency across composables verified. Co-Authored-By: Claude Opus 4.7 (1M context) --- cspell-words.txt | 5 + ...05-12-portal-redesign-quiet-luxury-plan.md | 2607 +++++++++++++++++ 2 files changed, 2612 insertions(+) create mode 100644 docs/superpowers/plans/2026-05-12-portal-redesign-quiet-luxury-plan.md diff --git a/cspell-words.txt b/cspell-words.txt index 101e7628..ef6373b8 100644 --- a/cspell-words.txt +++ b/cspell-words.txt @@ -941,3 +941,8 @@ Bento шир выс fadeup + +# Portal Redesign plan — Quiet Luxury (2026-05-12) +slideup +спан +токенах diff --git a/docs/superpowers/plans/2026-05-12-portal-redesign-quiet-luxury-plan.md b/docs/superpowers/plans/2026-05-12-portal-redesign-quiet-luxury-plan.md new file mode 100644 index 00000000..c4f5a8b8 --- /dev/null +++ b/docs/superpowers/plans/2026-05-12-portal-redesign-quiet-luxury-plan.md @@ -0,0 +1,2607 @@ +# Portal Redesign — Quiet Luxury Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Превратить визуальный слой Лидерры в «дорогой и технологичный», совместимый с 8-часовой работой, без изменения IA/маршрутов/бизнес-логики. + +**Architecture:** Три CSS-foundation файла (`tokens.css` / `typography.css` / `motion.css`) → расширенная Vuetify-тема с глобальными defaults → 3 composables (`useStatusPill`, `useCountUp`, `useDensity`) → 4 ui-компонента (`StatusPill`, `Kbd`, `FilterChip`, `DensityToggle`) → редизайн `AppSidebar` → page-transition wiring → применение к 4 ключевым view'ам (Dashboard, Deals, Kanban, Projects). + +**Tech Stack:** Vue 3.5 + Vuetify 3.12 + TypeScript 6 + Vitest 4 + Histoire 1.0-beta + ESLint 10 + Prettier 3 + Pa11y. Без новых runtime-зависимостей (motion-runtime библиотеки пока не добавляются — CSS @keyframes + Vue `` + View Transitions API покрывают 7 motion-паттернов). + +**Spec:** [docs/superpowers/specs/2026-05-12-portal-redesign-quiet-luxury-design.md](../specs/2026-05-12-portal-redesign-quiet-luxury-design.md) + +**Базовая ветка:** `plan5-frontend-projects` (текущая, head `17e07fb`). + +--- + +## File Structure + +**New files:** + +- `app/resources/css/tokens.css` — 12 цвет-токенов + 8 spacing + 6 радиусов + 4 shadow (CSS vars) +- `app/resources/css/typography.css` — font-imports (Inter variable + JetBrains Mono) + font-feature-settings +- `app/resources/css/motion.css` — @keyframes (shimmer, fadeup, slideup, pulse, dialog-in) + prefers-reduced-motion wrapper +- `app/resources/js/composables/useStatusPill.ts` — slug→стилевые токены (14 slugs из `db/schema.sql`) +- `app/resources/js/composables/useCountUp.ts` — RAF tween, respects `prefers-reduced-motion` +- `app/resources/js/composables/useDensity.ts` — localStorage-reactive density (comfortable/compact) +- `app/resources/js/components/ui/StatusPill.vue` — pill для 14 lead_statuses +- `app/resources/js/components/ui/StatusPill.story.vue` — Histoire story (14 вариантов) +- `app/resources/js/components/ui/Kbd.vue` — клавишный спан (для ⌘K, /, Esc) +- `app/resources/js/components/ui/Kbd.story.vue` +- `app/resources/js/components/ui/FilterChip.vue` — фильтр-чип с count + active +- `app/resources/js/components/ui/FilterChip.story.vue` +- `app/resources/js/components/ui/DensityToggle.vue` — toggle compact/comfortable +- `app/resources/js/components/ui/DensityToggle.story.vue` +- `app/tests/Frontend/tokens-css.spec.ts` +- `app/tests/Frontend/typography-css.spec.ts` +- `app/tests/Frontend/motion-css.spec.ts` +- `app/tests/Frontend/useStatusPill.spec.ts` +- `app/tests/Frontend/useCountUp.spec.ts` +- `app/tests/Frontend/useDensity.spec.ts` +- `app/tests/Frontend/StatusPill.spec.ts` +- `app/tests/Frontend/Kbd.spec.ts` +- `app/tests/Frontend/FilterChip.spec.ts` +- `app/tests/Frontend/DensityToggle.spec.ts` +- `app/tests/Frontend/AppSidebarRedesign.spec.ts` + +**Modified files:** + +- `app/resources/js/plugins/vuetify.ts` — расширить `liderraForest.colors` до 12 ключей + global defaults для VBtn/VTextField/VCard/VChip/VDataTable +- `app/resources/js/app.ts` (или `bootstrap.ts`) — импорт трёх CSS foundation файлов первой строкой +- `app/resources/js/components/layout/AppSidebar.vue` — двухтоновый shell, group-labels per spec, badge JetBrains Mono, ⌘K stub, active marker (motion #7) +- `app/resources/js/views/DashboardView.vue` — KPI count-up (motion #1), live pulse +- `app/resources/js/views/DealsView.vue` — filterbar + density toggle + table stagger (motion #2) + hover lift (motion #4) + StatusPill во всех колонках статуса +- `app/resources/js/views/KanbanView.vue` — StatusPill в карточках + hover lift +- `app/resources/js/views/ProjectsView.vue` — карточки на новых токенах + hover lift +- `app/resources/js/router/index.ts` — `transition: 'page'` meta-field + View Transitions API wrapper + +**Convention:** + +- Тесты идут в `app/tests/Frontend/*.spec.ts` (Vitest), команда `npm run test:vue` запускает все. +- Импорты в исходных файлах **относительные** (`../composables/...`), без `@/` алиаса (он не настроен). +- ESLint `@typescript-eslint/no-explicit-any` — блокирует `any`; типизировать через `Record` или конкретные интерфейсы. +- Vitest unit-tests запускаются из `app/` директории; `composer test` запускает Pest (бэкенд). +- Каждая задача завершается зелёным `npm run test:vue` + `npm run lint:vue` и git commit. + +--- + +## Phase A — CSS Foundation (Tasks 1–3) + +### Task 1: tokens.css — палитра, spacing, радиусы, тени + +**Files:** + +- Create: `app/resources/css/tokens.css` +- Test: `app/tests/Frontend/tokens-css.spec.ts` +- Modify: `app/resources/js/app.ts` (импорт сразу после Vuetify CSS) + +- [ ] **Step 1: Write the failing test** + +```typescript +// app/tests/Frontend/tokens-css.spec.ts +import { describe, it, expect } from 'vitest'; +import fs from 'node:fs'; +import path from 'node:path'; + +const cssPath = path.resolve(__dirname, '../../resources/css/tokens.css'); +const css = fs.existsSync(cssPath) ? fs.readFileSync(cssPath, 'utf-8') : ''; + +describe('tokens.css', () => { + it.each([ + ['--liderra-teal', '#0F6E56'], + ['--liderra-teal-deep', '#0A5A47'], + ['--liderra-noir', '#012019'], + ['--liderra-ivory', '#F6F3EC'], + ['--liderra-surface', '#FFFFFF'], + ['--liderra-muted', '#6B6356'], + ['--liderra-success', '#2E8B57'], + ['--liderra-saffron', '#D9A441'], + ['--liderra-error', '#B83A3A'], + ['--liderra-info', '#3F7C95'], + ['--liderra-plum', '#7A5BA3'], + ['--liderra-salmon', '#CC6E50'], + ])('exposes %s = %s', (name, value) => { + const re = new RegExp(`${name}\\s*:\\s*${value}`, 'i'); + expect(css).toMatch(re); + }); + + it.each([ + ['--space-1', '4px'], + ['--space-2', '8px'], + ['--space-3', '12px'], + ['--space-4', '16px'], + ['--space-6', '24px'], + ['--space-8', '32px'], + ['--space-12', '48px'], + ['--space-16', '64px'], + ])('exposes spacing %s = %s', (name, value) => { + expect(css).toMatch(new RegExp(`${name}\\s*:\\s*${value}`)); + }); + + it.each(['--radius-6', '--radius-8', '--radius-10', '--radius-12', '--radius-14', '--radius-full'])( + 'exposes radius %s', + (name) => { + expect(css).toMatch(new RegExp(`${name}\\s*:`)); + }, + ); + + it.each(['--shadow-1', '--shadow-2', '--shadow-3', '--shadow-4'])('exposes shadow %s', (name) => { + expect(css).toMatch(new RegExp(`${name}\\s*:`)); + }); + + it('exposes --liderra-line and --liderra-line-strong', () => { + expect(css).toMatch(/--liderra-line:\s*rgba\(1,\s*32,\s*25,\s*0\.08\)/); + expect(css).toMatch(/--liderra-line-strong:\s*rgba\(1,\s*32,\s*25,\s*0\.14\)/); + }); +}); +``` + +- [ ] **Step 2: Run test, expect FAIL** + +Run from `app/`: `npm run test:vue -- tokens-css.spec.ts` +Expected: 26+ FAILs (`expected '' to match ...`). + +- [ ] **Step 3: Implement tokens.css** + +```css +/* app/resources/css/tokens.css + * Liderra Forest design tokens (Iteration 1 — Quiet Luxury). + * Spec: docs/superpowers/specs/2026-05-12-portal-redesign-quiet-luxury-design.md + */ +:root { + /* ===== Палитра (12 токенов) ===== */ + --liderra-teal: #0F6E56; + --liderra-teal-deep: #0A5A47; + --liderra-noir: #012019; + --liderra-ivory: #F6F3EC; + --liderra-surface: #FFFFFF; + --liderra-muted: #6B6356; + --liderra-success: #2E8B57; + --liderra-saffron: #D9A441; + --liderra-error: #B83A3A; + --liderra-info: #3F7C95; + --liderra-plum: #7A5BA3; + --liderra-salmon: #CC6E50; + + /* ===== Тонкие поверхности ===== */ + --liderra-line: rgba(1, 32, 25, 0.08); + --liderra-line-strong: rgba(1, 32, 25, 0.14); + + /* ===== Spacing (4pt grid) ===== */ + --space-1: 4px; + --space-2: 8px; + --space-3: 12px; + --space-4: 16px; + --space-6: 24px; + --space-8: 32px; + --space-12: 48px; + --space-16: 64px; + + /* ===== Радиусы ===== */ + --radius-6: 6px; + --radius-8: 8px; + --radius-10: 10px; + --radius-12: 12px; + --radius-14: 14px; + --radius-full: 999px; + + /* ===== Shadows (ambient + key, двухслойные) ===== */ + --shadow-1: 0 1px 2px rgba(1, 32, 25, 0.04); + --shadow-2: 0 4px 12px rgba(1, 32, 25, 0.06), 0 1px 2px rgba(1, 32, 25, 0.04); + --shadow-3: 0 12px 28px rgba(1, 32, 25, 0.10); + --shadow-4: 0 24px 48px rgba(1, 32, 25, 0.16); +} +``` + +- [ ] **Step 4: Import in entry point** + +Read `app/resources/js/app.ts` first to find Vuetify import line. Add `import '../css/tokens.css';` directly after the Vuetify CSS import (so tokens are available globally). + +- [ ] **Step 5: Run test, expect PASS** + +Run: `npm run test:vue -- tokens-css.spec.ts` +Expected: all PASS. + +- [ ] **Step 6: Lint + commit** + +```bash +cd app && npm run lint:vue && npm run format +git add app/resources/css/tokens.css app/tests/Frontend/tokens-css.spec.ts app/resources/js/app.ts +git commit -m "feat(redesign): Task 1 — tokens.css (12 colors + spacing + radii + shadows)" +``` + +--- + +### Task 2: typography.css — Inter variable + JetBrains Mono + tnum + +**Files:** + +- Create: `app/resources/css/typography.css` +- Test: `app/tests/Frontend/typography-css.spec.ts` +- Modify: `app/resources/js/app.ts` (импорт после tokens.css) + +- [ ] **Step 1: Write the failing test** + +```typescript +// app/tests/Frontend/typography-css.spec.ts +import { describe, it, expect } from 'vitest'; +import fs from 'node:fs'; +import path from 'node:path'; + +const cssPath = path.resolve(__dirname, '../../resources/css/typography.css'); +const css = fs.existsSync(cssPath) ? fs.readFileSync(cssPath, 'utf-8') : ''; + +describe('typography.css', () => { + it('imports Inter variable font', () => { + expect(css).toMatch(/@import\s+url\([^)]*fonts\.googleapis\.com[^)]*Inter[^)]*\)/i); + }); + + it('imports JetBrains Mono', () => { + expect(css).toMatch(/@import\s+url\([^)]*fonts\.googleapis\.com[^)]*JetBrains[+\s]?Mono/i); + }); + + it('sets Inter as default body font with tnum', () => { + expect(css).toMatch(/font-family:[^;]*['"]?Inter['"]?[^;]*system-ui/); + expect(css).toMatch(/font-feature-settings:[^;]*['"]?tnum['"]?\s*1/); + }); + + it('defines .ld-mono utility for JetBrains Mono with tnum', () => { + expect(css).toMatch(/\.ld-mono\s*{[^}]*font-family:[^;]*['"]?JetBrains Mono['"]?/); + expect(css).toMatch(/\.ld-mono\s*{[^}]*font-feature-settings:[^;]*['"]?tnum['"]?/); + }); + + it.each(['ld-label', 'ld-body', 'ld-body-strong', 'ld-h3', 'ld-h2', 'ld-h1'])( + 'defines %s class', + (cls) => { + expect(css).toMatch(new RegExp(`\\.${cls}\\s*{`)); + }, + ); +}); +``` + +- [ ] **Step 2: Run test, expect FAIL** + +Run: `npm run test:vue -- typography-css.spec.ts` +Expected: 8+ FAILs. + +- [ ] **Step 3: Implement typography.css** + +```css +/* app/resources/css/typography.css + * Liderra typography — Inter (UI) + JetBrains Mono (numerics) с tnum. + */ +@import url('https://fonts.googleapis.com/css2?family=Inter:opsz,wght@14..32,300..700&display=swap'); +@import url('https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;500;600&display=swap'); + +html, +body { + font-family: 'Inter', system-ui, -apple-system, 'Segoe UI', Roboto, sans-serif; + font-feature-settings: 'tnum' 1, 'cv11' 1; + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; +} + +.ld-mono { + font-family: 'JetBrains Mono', ui-monospace, 'SF Mono', Menlo, Consolas, monospace; + font-feature-settings: 'tnum' 1; + letter-spacing: -0.01em; +} + +/* Шкала (см. spec §4) */ +.ld-label { + font-size: 11px; + line-height: 14px; + font-weight: 500; + letter-spacing: 0.10em; + text-transform: uppercase; + color: var(--liderra-muted); +} + +.ld-body { + font-size: 13px; + line-height: 20px; + font-weight: 400; +} + +.ld-body-strong { + font-size: 15px; + line-height: 22px; + font-weight: 500; +} + +.ld-h3 { + font-size: 17px; + line-height: 24px; + font-weight: 600; + letter-spacing: -0.01em; +} + +.ld-h2 { + font-size: 22px; + line-height: 28px; + font-weight: 600; + letter-spacing: -0.015em; +} + +.ld-h1 { + font-size: 28px; + line-height: 36px; + font-weight: 600; + letter-spacing: -0.02em; +} + +.ld-hero { + font-size: clamp(30px, 5vw, 48px); + font-weight: 600; + letter-spacing: -0.025em; + line-height: 1.1; +} + +.ld-mono-xl { + font-family: 'JetBrains Mono', ui-monospace, monospace; + font-size: 28px; + font-weight: 500; + letter-spacing: -0.02em; + font-feature-settings: 'tnum' 1; +} + +.ld-mono-s { + font-family: 'JetBrains Mono', ui-monospace, monospace; + font-size: 11px; + font-weight: 400; + font-feature-settings: 'tnum' 1; +} +``` + +- [ ] **Step 4: Import in entry point** + +В `app/resources/js/app.ts` после `import '../css/tokens.css';` добавить `import '../css/typography.css';`. + +- [ ] **Step 5: Run test, expect PASS** + +Run: `npm run test:vue -- typography-css.spec.ts` +Expected: 11 PASS. + +- [ ] **Step 6: Lint + commit** + +```bash +cd app && npm run format +git add app/resources/css/typography.css app/tests/Frontend/typography-css.spec.ts app/resources/js/app.ts +git commit -m "feat(redesign): Task 2 — typography.css (Inter variable + JetBrains Mono + tnum)" +``` + +--- + +### Task 3: motion.css — keyframes + prefers-reduced-motion wrapper + +**Files:** + +- Create: `app/resources/css/motion.css` +- Test: `app/tests/Frontend/motion-css.spec.ts` +- Modify: `app/resources/js/app.ts` + +- [ ] **Step 1: Write the failing test** + +```typescript +// app/tests/Frontend/motion-css.spec.ts +import { describe, it, expect } from 'vitest'; +import fs from 'node:fs'; +import path from 'node:path'; + +const cssPath = path.resolve(__dirname, '../../resources/css/motion.css'); +const css = fs.existsSync(cssPath) ? fs.readFileSync(cssPath, 'utf-8') : ''; + +describe('motion.css', () => { + it.each(['ld-fadeup', 'ld-slideup', 'ld-shimmer', 'ld-pulse', 'ld-dialog-in'])( + '@keyframes %s defined', + (name) => { + expect(css).toMatch(new RegExp(`@keyframes\\s+${name}\\s*{`)); + }, + ); + + it('prefers-reduced-motion wrapper disables animations', () => { + const block = css.match(/@media\s*\(\s*prefers-reduced-motion:\s*reduce\s*\)[^{]*{[\s\S]*?}\s*}\s*}/); + expect(block, 'prefers-reduced-motion @media block').not.toBeNull(); + expect(block?.[0]).toMatch(/animation-duration:\s*0\.01ms\s*!important/); + expect(block?.[0]).toMatch(/transition-duration:\s*0\.01ms\s*!important/); + expect(block?.[0]).toMatch(/animation-iteration-count:\s*1\s*!important/); + }); + + it('defines .ld-hover-lift utility (motion #4)', () => { + expect(css).toMatch(/\.ld-hover-lift\s*{/); + expect(css).toMatch(/transform:\s*translateY\(-2px\)/); + }); + + it('defines .ld-stagger-row utility (motion #2) with nth-child delays', () => { + expect(css).toMatch(/\.ld-stagger-row\s*{/); + expect(css).toMatch(/animation:\s*ld-slideup/); + expect(css).toMatch(/\.ld-stagger-row:nth-child\(\d+\)/); + }); +}); +``` + +- [ ] **Step 2: Run test, expect FAIL** + +Run: `npm run test:vue -- motion-css.spec.ts` + +- [ ] **Step 3: Implement motion.css** + +```css +/* app/resources/css/motion.css + * Liderra motion-инфраструктура. 7 паттернов + reduced-motion wrapper. + * Spec: §9. + */ + +/* === keyframes === */ +@keyframes ld-fadeup { + from { opacity: 0; transform: translateY(4px); } + to { opacity: 1; transform: none; } +} + +@keyframes ld-slideup { + from { opacity: 0; transform: translateY(8px); } + to { opacity: 1; transform: none; } +} + +@keyframes ld-shimmer { + 0% { background-position: -200px 0; } + 100% { background-position: 200px 0; } +} + +@keyframes ld-pulse { + 0%, 100% { transform: scale(1); opacity: 1; } + 50% { transform: scale(1.6); opacity: 0.4; } +} + +@keyframes ld-dialog-in { + 0% { opacity: 0; transform: scale(0.94) translateY(8px); } + 100% { opacity: 1; transform: scale(1) translateY(0); } +} + +/* === Utilities === */ + +/* motion #4 — Hover lift */ +.ld-hover-lift { + transition: + transform 200ms cubic-bezier(0.16, 1, 0.3, 1), + box-shadow 200ms cubic-bezier(0.16, 1, 0.3, 1); +} + +.ld-hover-lift:hover { + transform: translateY(-2px); + box-shadow: var(--shadow-2); +} + +/* motion #2 — Stagger list (применяется к строкам таблиц/списков; mount-only) */ +.ld-stagger-row { + animation: ld-slideup 400ms cubic-bezier(0.16, 1, 0.3, 1) backwards; +} +.ld-stagger-row:nth-child(1) { animation-delay: 0ms; } +.ld-stagger-row:nth-child(2) { animation-delay: 50ms; } +.ld-stagger-row:nth-child(3) { animation-delay: 100ms; } +.ld-stagger-row:nth-child(4) { animation-delay: 150ms; } +.ld-stagger-row:nth-child(5) { animation-delay: 200ms; } +.ld-stagger-row:nth-child(6) { animation-delay: 250ms; } +.ld-stagger-row:nth-child(7) { animation-delay: 300ms; } +.ld-stagger-row:nth-child(8) { animation-delay: 350ms; } +.ld-stagger-row:nth-child(9) { animation-delay: 400ms; } +.ld-stagger-row:nth-child(10) { animation-delay: 450ms; } + +/* motion #5 — Skeleton shimmer */ +.ld-skeleton { + background: linear-gradient( + 90deg, + rgba(1, 32, 25, 0.06) 0%, + rgba(1, 32, 25, 0.12) 50%, + rgba(1, 32, 25, 0.06) 100% + ); + background-size: 400px 100%; + animation: ld-shimmer 1400ms infinite linear; + border-radius: var(--radius-6); +} + +/* motion #10 (auxiliary) — Live pulse */ +.ld-pulse { + position: relative; + display: inline-block; + width: 8px; + height: 8px; + border-radius: var(--radius-full); + background: var(--liderra-teal); +} + +.ld-pulse::after { + content: ''; + position: absolute; + inset: 0; + border-radius: var(--radius-full); + background: var(--liderra-teal); + animation: ld-pulse 1800ms infinite cubic-bezier(0.4, 0, 0.6, 1); +} + +/* motion #6 — Page transition (View Transitions API + CSS fallback) */ +::view-transition-old(root), +::view-transition-new(root) { + animation-duration: 280ms; + animation-timing-function: cubic-bezier(0.16, 1, 0.3, 1); +} + +::view-transition-old(root) { + animation-name: ld-fadeout-up; +} + +::view-transition-new(root) { + animation-name: ld-fadeup; +} + +@keyframes ld-fadeout-up { + from { opacity: 1; transform: none; } + to { opacity: 0; transform: translateY(-4px); } +} + +/* CSS fallback для router transition */ +.ld-route-fadeup-enter-active, +.ld-route-fadeup-leave-active { + transition: opacity 280ms cubic-bezier(0.16, 1, 0.3, 1), + transform 280ms cubic-bezier(0.16, 1, 0.3, 1); +} + +.ld-route-fadeup-enter-from { opacity: 0; transform: translateY(4px); } +.ld-route-fadeup-leave-to { opacity: 0; transform: translateY(-4px); } + +/* === Reduced motion — отключаем всё === */ +@media (prefers-reduced-motion: reduce) { + *, + *::before, + *::after { + animation-duration: 0.01ms !important; + animation-iteration-count: 1 !important; + transition-duration: 0.01ms !important; + } +} +``` + +- [ ] **Step 4: Import in entry point** + +В `app/resources/js/app.ts` после typography: `import '../css/motion.css';`. + +- [ ] **Step 5: Run test, expect PASS** + +Run: `npm run test:vue -- motion-css.spec.ts` + +- [ ] **Step 6: Lint + commit** + +```bash +cd app && npm run format +git add app/resources/css/motion.css app/tests/Frontend/motion-css.spec.ts app/resources/js/app.ts +git commit -m "feat(redesign): Task 3 — motion.css (5 keyframes + reduced-motion wrapper + utilities)" +``` + +--- + +## Phase B — Vuetify Theme + Globals (Task 4) + +### Task 4: Vuetify theme расширение + global defaults + +**Files:** + +- Modify: `app/resources/js/plugins/vuetify.ts` +- Test: `app/tests/Frontend/vuetifyTheme.spec.ts` + +- [ ] **Step 1: Write the failing test** + +```typescript +// app/tests/Frontend/vuetifyTheme.spec.ts +import { describe, it, expect } from 'vitest'; +import { vuetify } from '../../resources/js/plugins/vuetify'; + +describe('Vuetify theme — liderraForest', () => { + const theme = vuetify.theme.themes.value.liderraForest; + + it.each([ + ['primary', '#0F6E56'], + ['secondary', '#012019'], + ['background', '#F6F3EC'], + ['surface', '#FFFFFF'], + ['success', '#2E8B57'], + ['warning', '#D9A441'], + ['error', '#B83A3A'], + ['info', '#3F7C95'], + ['liderra-plum', '#7A5BA3'], + ['liderra-salmon', '#CC6E50'], + ['liderra-teal-deep', '#0A5A47'], + ['liderra-muted', '#6B6356'], + ])('color %s = %s', (key, value) => { + expect(theme.colors[key]?.toUpperCase()).toBe(value.toUpperCase()); + }); +}); + +describe('Vuetify global defaults', () => { + it('VBtn defaults to flat variant', () => { + expect((vuetify as unknown as { defaults: { value: Record> } }).defaults.value.VBtn?.variant).toBe('flat'); + }); + + it('VCard defaults to rounded lg + variant flat + border', () => { + const card = (vuetify as unknown as { defaults: { value: Record> } }).defaults.value.VCard; + expect(card?.rounded).toBe('lg'); + expect(card?.variant).toBe('flat'); + expect(card?.border).toBe(true); + }); + + it('VTextField defaults to outlined + density comfortable', () => { + const tf = (vuetify as unknown as { defaults: { value: Record> } }).defaults.value.VTextField; + expect(tf?.variant).toBe('outlined'); + expect(tf?.density).toBe('comfortable'); + }); +}); +``` + +- [ ] **Step 2: Run test, expect FAIL** + +Run: `npm run test:vue -- vuetifyTheme.spec.ts` + +- [ ] **Step 3: Update plugins/vuetify.ts** + +```typescript +// app/resources/js/plugins/vuetify.ts +// @ts-expect-error vuetify/styles — CSS-импорт без d.ts +import 'vuetify/styles'; +import { createVuetify } from 'vuetify'; +import type { ThemeDefinition } from 'vuetify'; + +/** + * Палитра Forest extended (Iteration 1 — Quiet Luxury redesign). + * Spec: docs/superpowers/specs/2026-05-12-portal-redesign-quiet-luxury-design.md §3 + * CSS-токены: app/resources/css/tokens.css (single source of truth) + * + * 14 OKLCH-статусов воронки маппятся на slugs из db/schema.sql:2076 (lead_statuses) + * через `useStatusPill` composable, НЕ через Vuetify theme. + */ +const liderraForest: ThemeDefinition = { + dark: false, + colors: { + background: '#F6F3EC', + surface: '#FFFFFF', + primary: '#0F6E56', + 'on-primary': '#FFFFFF', + secondary: '#012019', + 'on-secondary': '#F6F3EC', + success: '#2E8B57', + warning: '#D9A441', + error: '#B83A3A', + info: '#3F7C95', + // Расширения — для data viz и semantic uses + 'liderra-plum': '#7A5BA3', + 'liderra-salmon': '#CC6E50', + 'liderra-teal-deep': '#0A5A47', + 'liderra-muted': '#6B6356', + }, +}; + +export const vuetify = createVuetify({ + theme: { + defaultTheme: 'liderraForest', + themes: { liderraForest }, + }, + defaults: { + VBtn: { + variant: 'flat', + rounded: 'lg', + }, + VCard: { + rounded: 'lg', + variant: 'flat', + border: true, + }, + VTextField: { + variant: 'outlined', + density: 'comfortable', + color: 'primary', + }, + VTextarea: { + variant: 'outlined', + density: 'comfortable', + color: 'primary', + }, + VSelect: { + variant: 'outlined', + density: 'comfortable', + }, + VChip: { + rounded: 'pill', + size: 'small', + }, + VDataTable: { + density: 'comfortable', + }, + VDialog: { + scrim: 'rgba(1, 32, 25, 0.32)', + }, + }, +}); +``` + +- [ ] **Step 4: Run test, expect PASS** + +Run: `npm run test:vue -- vuetifyTheme.spec.ts` + +- [ ] **Step 5: Run full test suite as smoke check** + +Run: `npm run test:vue` +Expected: все ранее зелёные тесты остаются зелёными (некоторые snapshot'ы могут потребовать обновления при изменении defaults — если они есть, переутвердить через `npm run test:vue -- -u` только если изменения визуально соответствуют spec'у). + +- [ ] **Step 6: Lint + commit** + +```bash +cd app && npm run lint:vue && npm run format +git add app/resources/js/plugins/vuetify.ts app/tests/Frontend/vuetifyTheme.spec.ts +git commit -m "feat(redesign): Task 4 — extend Vuetify theme (12 colors) + global component defaults" +``` + +--- + +## Phase C — Composables (Tasks 5–7) + +### Task 5: useStatusPill composable (14 slugs) + +**Files:** + +- Create: `app/resources/js/composables/useStatusPill.ts` +- Test: `app/tests/Frontend/useStatusPill.spec.ts` + +- [ ] **Step 1: Write the failing test** + +```typescript +// app/tests/Frontend/useStatusPill.spec.ts +import { describe, it, expect } from 'vitest'; +import { useStatusPill, STATUS_PILL_SLUGS } from '../../resources/js/composables/useStatusPill'; + +describe('useStatusPill', () => { + it('exposes exactly 14 known slugs', () => { + expect(STATUS_PILL_SLUGS).toHaveLength(14); + expect(STATUS_PILL_SLUGS).toEqual( + expect.arrayContaining([ + 'new', + 'in_progress', + 'callback', + 'quality', + 'meeting_set', + 'won', + 'refund', + 'duplicate', + 'junk', + 'no_answer', + 'cancelled', + 'closed', + 'postponed', + 'archived', + ]), + ); + }); + + it.each([ + ['new', { bg: 'rgba(15,110,86,0.12)', color: '#0F6E56' }], + ['in_progress', { bg: 'rgba(63,124,149,0.12)', color: '#3F7C95' }], + ['callback', { bg: 'rgba(217,164,65,0.18)', color: '#A07820' }], + ['quality', { bg: 'rgba(46,139,87,0.15)', color: '#2E8B57' }], + ['meeting_set', { bg: 'rgba(122,91,163,0.15)', color: '#7A5BA3' }], + ['won', { bg: 'rgba(46,139,87,0.22)', color: '#1F6940', fontWeight: 600 }], + ['refund', { bg: 'rgba(204,110,80,0.15)', color: '#B0563D' }], + ['duplicate', { bg: 'rgba(1,32,25,0.08)', color: '#3A3A3A' }], + ['junk', { bg: 'rgba(184,58,58,0.10)', color: '#B83A3A' }], + ['no_answer', { bg: 'rgba(107,99,86,0.15)', color: '#6B6356' }], + ['cancelled', { bg: 'rgba(107,99,86,0.18)', color: '#6B6356', textDecoration: 'line-through' }], + ['closed', { bg: 'rgba(1,32,25,0.10)', color: '#3A3A3A' }], + ['postponed', { bg: 'rgba(15,110,86,0.06)', color: '#6B6356' }], + ['archived', { bg: '#012019', color: '#E8E2D4' }], + ])('returns correct tokens for %s', (slug, expected) => { + const result = useStatusPill(slug); + expect(result).toMatchObject(expected); + }); + + it('falls back to a neutral style for unknown slug', () => { + const result = useStatusPill('unknown_slug'); + expect(result.bg).toMatch(/rgba\(1, ?32, ?25/); + expect(result.color).toBeDefined(); + }); +}); +``` + +- [ ] **Step 2: Run test, expect FAIL** + +Run: `npm run test:vue -- useStatusPill.spec.ts` + +- [ ] **Step 3: Implement useStatusPill.ts** + +```typescript +// app/resources/js/composables/useStatusPill.ts +/** + * Маппинг slug'ов lead_statuses → стилевые токены пилюли. + * Slugs синхронизированы с db/schema.sql:2076 (источник истины). + * + * Spec §8. Используется компонентом StatusPill.vue. + */ +export interface PillStyle { + bg: string; + color: string; + fontWeight?: number; + textDecoration?: 'line-through' | 'none'; +} + +export const STATUS_PILL_SLUGS = [ + 'new', + 'in_progress', + 'callback', + 'quality', + 'meeting_set', + 'won', + 'refund', + 'duplicate', + 'junk', + 'no_answer', + 'cancelled', + 'closed', + 'postponed', + 'archived', +] as const; + +export type StatusPillSlug = (typeof STATUS_PILL_SLUGS)[number]; + +const STYLES: Record = { + new: { bg: 'rgba(15,110,86,0.12)', color: '#0F6E56' }, + in_progress: { bg: 'rgba(63,124,149,0.12)', color: '#3F7C95' }, + callback: { bg: 'rgba(217,164,65,0.18)', color: '#A07820' }, + quality: { bg: 'rgba(46,139,87,0.15)', color: '#2E8B57' }, + meeting_set: { bg: 'rgba(122,91,163,0.15)', color: '#7A5BA3' }, + won: { bg: 'rgba(46,139,87,0.22)', color: '#1F6940', fontWeight: 600 }, + refund: { bg: 'rgba(204,110,80,0.15)', color: '#B0563D' }, + duplicate: { bg: 'rgba(1,32,25,0.08)', color: '#3A3A3A' }, + junk: { bg: 'rgba(184,58,58,0.10)', color: '#B83A3A' }, + no_answer: { bg: 'rgba(107,99,86,0.15)', color: '#6B6356' }, + cancelled: { bg: 'rgba(107,99,86,0.18)', color: '#6B6356', textDecoration: 'line-through' }, + closed: { bg: 'rgba(1,32,25,0.10)', color: '#3A3A3A' }, + postponed: { bg: 'rgba(15,110,86,0.06)', color: '#6B6356' }, + archived: { bg: '#012019', color: '#E8E2D4' }, +}; + +const FALLBACK: PillStyle = { bg: 'rgba(1,32,25,0.08)', color: '#3A3A3A' }; + +export function useStatusPill(slug: string): PillStyle { + return STYLES[slug as StatusPillSlug] ?? FALLBACK; +} +``` + +- [ ] **Step 4: Run test, expect PASS** + +Run: `npm run test:vue -- useStatusPill.spec.ts` + +- [ ] **Step 5: Lint + commit** + +```bash +cd app && npm run lint:vue && npm run format +git add app/resources/js/composables/useStatusPill.ts app/tests/Frontend/useStatusPill.spec.ts +git commit -m "feat(redesign): Task 5 — useStatusPill composable (14 slugs из db/schema.sql)" +``` + +--- + +### Task 6: useCountUp composable (RAF tween + prefers-reduced-motion) + +**Files:** + +- Create: `app/resources/js/composables/useCountUp.ts` +- Test: `app/tests/Frontend/useCountUp.spec.ts` + +- [ ] **Step 1: Write the failing test** + +```typescript +// app/tests/Frontend/useCountUp.spec.ts +import { describe, it, expect, beforeEach, vi } from 'vitest'; +import { ref, nextTick } from 'vue'; +import { useCountUp } from '../../resources/js/composables/useCountUp'; + +function mockMatchMedia(reduced: boolean): void { + Object.defineProperty(window, 'matchMedia', { + writable: true, + configurable: true, + value: vi.fn().mockImplementation((query: string) => ({ + matches: query.includes('reduce') ? reduced : false, + media: query, + onchange: null, + addEventListener: vi.fn(), + removeEventListener: vi.fn(), + addListener: vi.fn(), + removeListener: vi.fn(), + dispatchEvent: vi.fn(), + })), + }); +} + +describe('useCountUp', () => { + beforeEach(() => { + vi.useFakeTimers(); + mockMatchMedia(false); + }); + + it('exposes initial display = 0 before target propagates', async () => { + const target = ref(100); + const { display } = useCountUp(target, { duration: 600 }); + expect(display.value).toBe(0); + }); + + it('animates from 0 to target over duration', async () => { + const target = ref(100); + const { display, start } = useCountUp(target, { duration: 600 }); + start(); + // step through animation + await vi.advanceTimersByTimeAsync(300); + expect(display.value).toBeGreaterThan(0); + expect(display.value).toBeLessThan(100); + await vi.advanceTimersByTimeAsync(400); + expect(display.value).toBe(100); + }); + + it('respects prefers-reduced-motion (instant value, no animation)', async () => { + mockMatchMedia(true); + const target = ref(250); + const { display, start } = useCountUp(target, { duration: 600 }); + start(); + await nextTick(); + expect(display.value).toBe(250); + }); + + it('re-animates when target value changes', async () => { + const target = ref(50); + const { display, start } = useCountUp(target, { duration: 400 }); + start(); + await vi.advanceTimersByTimeAsync(500); + expect(display.value).toBe(50); + + target.value = 200; + await nextTick(); + await vi.advanceTimersByTimeAsync(500); + expect(display.value).toBe(200); + }); +}); +``` + +- [ ] **Step 2: Run test, expect FAIL** + +Run: `npm run test:vue -- useCountUp.spec.ts` + +- [ ] **Step 3: Implement useCountUp.ts** + +```typescript +// app/resources/js/composables/useCountUp.ts +import { ref, watch, type Ref } from 'vue'; + +export interface CountUpOptions { + duration?: number; // ms + precision?: number; // знаков после запятой +} + +export interface CountUpHandle { + display: Ref; + start: () => void; +} + +const easeOutQuint = (t: number): number => 1 - Math.pow(1 - t, 5); + +function prefersReducedMotion(): boolean { + if (typeof window === 'undefined' || !window.matchMedia) return false; + return window.matchMedia('(prefers-reduced-motion: reduce)').matches; +} + +export function useCountUp(target: Ref, opts: CountUpOptions = {}): CountUpHandle { + const duration = opts.duration ?? 600; + const precision = opts.precision ?? 0; + const display = ref(0); + let raf: number | null = null; + let startTime = 0; + let fromValue = 0; + + function tick(now: number): void { + const elapsed = now - startTime; + const t = Math.min(elapsed / duration, 1); + const eased = easeOutQuint(t); + const value = fromValue + (target.value - fromValue) * eased; + display.value = precision === 0 ? Math.round(value) : parseFloat(value.toFixed(precision)); + if (t < 1) { + raf = requestAnimationFrame(tick); + } else { + display.value = target.value; + raf = null; + } + } + + function start(): void { + if (prefersReducedMotion()) { + display.value = target.value; + return; + } + if (raf !== null) cancelAnimationFrame(raf); + fromValue = display.value; + startTime = performance.now(); + raf = requestAnimationFrame(tick); + } + + // Re-anim при изменении target (но не при первом mount — это делает caller через start()) + watch(target, () => { + if (display.value !== target.value) start(); + }); + + return { display, start }; +} +``` + +- [ ] **Step 4: Run test, expect PASS** + +Run: `npm run test:vue -- useCountUp.spec.ts` + +Note: jsdom requestAnimationFrame fires synchronously via vi.useFakeTimers — if test hangs, mock RAF explicitly: `globalThis.requestAnimationFrame = (cb) => setTimeout(() => cb(performance.now()), 16) as unknown as number;` in beforeEach. + +- [ ] **Step 5: Lint + commit** + +```bash +cd app && npm run lint:vue && npm run format +git add app/resources/js/composables/useCountUp.ts app/tests/Frontend/useCountUp.spec.ts +git commit -m "feat(redesign): Task 6 — useCountUp composable (RAF tween + prefers-reduced-motion)" +``` + +--- + +### Task 7: useDensity composable (localStorage-reactive) + +**Files:** + +- Create: `app/resources/js/composables/useDensity.ts` +- Test: `app/tests/Frontend/useDensity.spec.ts` + +- [ ] **Step 1: Write the failing test** + +```typescript +// app/tests/Frontend/useDensity.spec.ts +import { describe, it, expect, beforeEach, vi } from 'vitest'; +import { useDensity, DENSITY_KEY } from '../../resources/js/composables/useDensity'; + +describe('useDensity', () => { + beforeEach(() => { + localStorage.clear(); + vi.restoreAllMocks(); + }); + + it('defaults to comfortable when localStorage is empty', () => { + const { density } = useDensity(); + expect(density.value).toBe('comfortable'); + }); + + it('reads existing localStorage value', () => { + localStorage.setItem(DENSITY_KEY, 'compact'); + const { density } = useDensity(); + expect(density.value).toBe('compact'); + }); + + it('toggle() flips between comfortable and compact', () => { + const { density, toggle } = useDensity(); + expect(density.value).toBe('comfortable'); + toggle(); + expect(density.value).toBe('compact'); + toggle(); + expect(density.value).toBe('comfortable'); + }); + + it('setDensity persists to localStorage', () => { + const { setDensity } = useDensity(); + setDensity('compact'); + expect(localStorage.getItem(DENSITY_KEY)).toBe('compact'); + }); + + it('coerces invalid localStorage value to comfortable', () => { + localStorage.setItem(DENSITY_KEY, 'garbage'); + const { density } = useDensity(); + expect(density.value).toBe('comfortable'); + }); + + it('rowHeight returns 44 for comfortable, 36 for compact', () => { + const { density, rowHeight, setDensity } = useDensity(); + setDensity('comfortable'); + expect(rowHeight.value).toBe(44); + setDensity('compact'); + expect(rowHeight.value).toBe(36); + // sanity: density still reactive + expect(density.value).toBe('compact'); + }); +}); +``` + +- [ ] **Step 2: Run test, expect FAIL** + +Run: `npm run test:vue -- useDensity.spec.ts` + +- [ ] **Step 3: Implement useDensity.ts** + +```typescript +// app/resources/js/composables/useDensity.ts +import { computed, ref, watch, type ComputedRef, type Ref } from 'vue'; + +export type Density = 'comfortable' | 'compact'; + +export const DENSITY_KEY = 'liderra:density'; + +export interface DensityHandle { + density: Ref; + rowHeight: ComputedRef; + setDensity: (d: Density) => void; + toggle: () => void; +} + +function loadInitial(): Density { + if (typeof localStorage === 'undefined') return 'comfortable'; + const raw = localStorage.getItem(DENSITY_KEY); + return raw === 'compact' ? 'compact' : 'comfortable'; +} + +export function useDensity(): DensityHandle { + const density = ref(loadInitial()); + + const rowHeight = computed(() => (density.value === 'compact' ? 36 : 44)); + + function setDensity(d: Density): void { + density.value = d; + } + + function toggle(): void { + density.value = density.value === 'comfortable' ? 'compact' : 'comfortable'; + } + + watch(density, (v) => { + if (typeof localStorage !== 'undefined') { + localStorage.setItem(DENSITY_KEY, v); + } + }); + + return { density, rowHeight, setDensity, toggle }; +} +``` + +- [ ] **Step 4: Run test, expect PASS** + +Run: `npm run test:vue -- useDensity.spec.ts` + +- [ ] **Step 5: Lint + commit** + +```bash +cd app && npm run lint:vue && npm run format +git add app/resources/js/composables/useDensity.ts app/tests/Frontend/useDensity.spec.ts +git commit -m "feat(redesign): Task 7 — useDensity composable (localStorage + rowHeight)" +``` + +--- + +## Phase D — UI Components (Tasks 8–11) + +### Task 8: StatusPill component + Histoire story + +**Files:** + +- Create: `app/resources/js/components/ui/StatusPill.vue` +- Create: `app/resources/js/components/ui/StatusPill.story.vue` +- Test: `app/tests/Frontend/StatusPill.spec.ts` + +- [ ] **Step 1: Write the failing test** + +```typescript +// app/tests/Frontend/StatusPill.spec.ts +import { describe, it, expect } from 'vitest'; +import { mount } from '@vue/test-utils'; +import StatusPill from '../../resources/js/components/ui/StatusPill.vue'; + +describe('StatusPill', () => { + it('renders label text', () => { + const w = mount(StatusPill, { props: { slug: 'new', label: 'Новый' } }); + expect(w.text()).toContain('Новый'); + }); + + it('falls back to slug when label not provided', () => { + const w = mount(StatusPill, { props: { slug: 'in_progress' } }); + expect(w.text()).toContain('in_progress'); + }); + + it.each(['new', 'in_progress', 'won', 'archived', 'cancelled'])( + 'applies correct background for %s', + (slug) => { + const w = mount(StatusPill, { props: { slug } }); + const style = w.attributes('style') ?? ''; + expect(style).toContain('background'); + expect(style).toContain('color'); + }, + ); + + it('cancelled slug applies line-through', () => { + const w = mount(StatusPill, { props: { slug: 'cancelled' } }); + expect(w.attributes('style') ?? '').toContain('line-through'); + }); + + it('won slug applies font-weight: 600', () => { + const w = mount(StatusPill, { props: { slug: 'won' } }); + expect(w.attributes('style') ?? '').toMatch(/font-weight:\s*600/); + }); +}); +``` + +- [ ] **Step 2: Run test, expect FAIL** + +Run: `npm run test:vue -- StatusPill.spec.ts` + +- [ ] **Step 3: Implement StatusPill.vue** + +```vue + + + + + + +``` + +- [ ] **Step 4: Histoire story** + +```vue + + + + +``` + +- [ ] **Step 5: Run test, expect PASS** + +Run: `npm run test:vue -- StatusPill.spec.ts` + +- [ ] **Step 6: Histoire smoke** + +Run: `npm run story:build 2>&1 | tail -30` +Expected: build succeeds, no errors, StatusPill story discovered. + +- [ ] **Step 7: Lint + commit** + +```bash +cd app && npm run lint:vue && npm run format +git add app/resources/js/components/ui/StatusPill.vue app/resources/js/components/ui/StatusPill.story.vue app/tests/Frontend/StatusPill.spec.ts +git commit -m "feat(redesign): Task 8 — StatusPill component + 14-variant Histoire story" +``` + +--- + +### Task 9: Kbd component (клавишный спан) + +**Files:** + +- Create: `app/resources/js/components/ui/Kbd.vue` +- Create: `app/resources/js/components/ui/Kbd.story.vue` +- Test: `app/tests/Frontend/Kbd.spec.ts` + +- [ ] **Step 1: Write the failing test** + +```typescript +// app/tests/Frontend/Kbd.spec.ts +import { describe, it, expect } from 'vitest'; +import { mount } from '@vue/test-utils'; +import Kbd from '../../resources/js/components/ui/Kbd.vue'; + +describe('Kbd', () => { + it('renders default slot content', () => { + const w = mount(Kbd, { slots: { default: () => '⌘K' } }); + expect(w.text()).toBe('⌘K'); + expect(w.element.tagName).toBe('KBD'); + }); + + it('applies dark variant when prop dark=true', () => { + const w = mount(Kbd, { + props: { dark: true }, + slots: { default: () => 'Esc' }, + }); + expect(w.classes()).toContain('ld-kbd--dark'); + }); +}); +``` + +- [ ] **Step 2: Run test, expect FAIL** + +Run: `npm run test:vue -- Kbd.spec.ts` + +- [ ] **Step 3: Implement Kbd.vue** + +```vue + + + + + + +``` + +- [ ] **Step 4: Histoire story** + +```vue + + + + +``` + +- [ ] **Step 5: Run test, expect PASS** + +Run: `npm run test:vue -- Kbd.spec.ts` + +- [ ] **Step 6: Lint + commit** + +```bash +cd app && npm run lint:vue && npm run format +git add app/resources/js/components/ui/Kbd.vue app/resources/js/components/ui/Kbd.story.vue app/tests/Frontend/Kbd.spec.ts +git commit -m "feat(redesign): Task 9 — Kbd component (⌘K, Esc badges; light+dark variants)" +``` + +--- + +### Task 10: FilterChip component + +**Files:** + +- Create: `app/resources/js/components/ui/FilterChip.vue` +- Create: `app/resources/js/components/ui/FilterChip.story.vue` +- Test: `app/tests/Frontend/FilterChip.spec.ts` + +- [ ] **Step 1: Write the failing test** + +```typescript +// app/tests/Frontend/FilterChip.spec.ts +import { describe, it, expect } from 'vitest'; +import { mount } from '@vue/test-utils'; +import FilterChip from '../../resources/js/components/ui/FilterChip.vue'; + +describe('FilterChip', () => { + it('renders label', () => { + const w = mount(FilterChip, { props: { label: 'Статус' } }); + expect(w.text()).toContain('Статус'); + }); + + it('renders count badge when count > 0', () => { + const w = mount(FilterChip, { props: { label: 'Статус', count: 3 } }); + expect(w.find('.ld-filter-chip__count').exists()).toBe(true); + expect(w.find('.ld-filter-chip__count').text()).toBe('3'); + }); + + it('hides count badge when count is 0 or undefined', () => { + expect(mount(FilterChip, { props: { label: 'X', count: 0 } }).find('.ld-filter-chip__count').exists()).toBe( + false, + ); + expect(mount(FilterChip, { props: { label: 'X' } }).find('.ld-filter-chip__count').exists()).toBe(false); + }); + + it('applies active class when active=true', () => { + const w = mount(FilterChip, { props: { label: 'X', active: true } }); + expect(w.classes()).toContain('ld-filter-chip--active'); + }); + + it('emits click on click', async () => { + const w = mount(FilterChip, { props: { label: 'X' } }); + await w.trigger('click'); + expect(w.emitted('click')).toHaveLength(1); + }); +}); +``` + +- [ ] **Step 2: Run test, expect FAIL** + +Run: `npm run test:vue -- FilterChip.spec.ts` + +- [ ] **Step 3: Implement FilterChip.vue** + +```vue + + + + + + +``` + +- [ ] **Step 4: Histoire story** + +```vue + + + + +``` + +- [ ] **Step 5: Run test, expect PASS** + +Run: `npm run test:vue -- FilterChip.spec.ts` + +- [ ] **Step 6: Lint + commit** + +```bash +cd app && npm run lint:vue && npm run format +git add app/resources/js/components/ui/FilterChip.vue app/resources/js/components/ui/FilterChip.story.vue app/tests/Frontend/FilterChip.spec.ts +git commit -m "feat(redesign): Task 10 — FilterChip component (label + count + active states)" +``` + +--- + +### Task 11: DensityToggle component + +**Files:** + +- Create: `app/resources/js/components/ui/DensityToggle.vue` +- Create: `app/resources/js/components/ui/DensityToggle.story.vue` +- Test: `app/tests/Frontend/DensityToggle.spec.ts` + +- [ ] **Step 1: Write the failing test** + +```typescript +// app/tests/Frontend/DensityToggle.spec.ts +import { describe, it, expect, beforeEach } from 'vitest'; +import { mount } from '@vue/test-utils'; +import DensityToggle from '../../resources/js/components/ui/DensityToggle.vue'; +import { DENSITY_KEY } from '../../resources/js/composables/useDensity'; + +describe('DensityToggle', () => { + beforeEach(() => { + localStorage.clear(); + }); + + it('renders two buttons: Компакт + Комфорт', () => { + const w = mount(DensityToggle); + const buttons = w.findAll('button'); + expect(buttons).toHaveLength(2); + expect(buttons[0].text()).toMatch(/Компакт/); + expect(buttons[1].text()).toMatch(/Комфорт/); + }); + + it('marks Comfortable as active by default', () => { + const w = mount(DensityToggle); + const comfortBtn = w.findAll('button')[1]; + expect(comfortBtn.classes()).toContain('ld-density-toggle__btn--active'); + }); + + it('clicking Компакт switches to compact and persists to localStorage', async () => { + const w = mount(DensityToggle); + const compactBtn = w.findAll('button')[0]; + await compactBtn.trigger('click'); + expect(compactBtn.classes()).toContain('ld-density-toggle__btn--active'); + expect(localStorage.getItem(DENSITY_KEY)).toBe('compact'); + }); + + it('emits change with new density value', async () => { + const w = mount(DensityToggle); + await w.findAll('button')[0].trigger('click'); + expect(w.emitted('change')).toBeTruthy(); + expect(w.emitted('change')?.[0]).toEqual(['compact']); + }); +}); +``` + +- [ ] **Step 2: Run test, expect FAIL** + +Run: `npm run test:vue -- DensityToggle.spec.ts` + +- [ ] **Step 3: Implement DensityToggle.vue** + +```vue + + + + + + +``` + +- [ ] **Step 4: Histoire story** + +```vue + + + + +``` + +- [ ] **Step 5: Run test, expect PASS** + +Run: `npm run test:vue -- DensityToggle.spec.ts` + +- [ ] **Step 6: Lint + commit** + +```bash +cd app && npm run lint:vue && npm run format +git add app/resources/js/components/ui/DensityToggle.vue app/resources/js/components/ui/DensityToggle.story.vue app/tests/Frontend/DensityToggle.spec.ts +git commit -m "feat(redesign): Task 11 — DensityToggle component (compact/comfortable + persist)" +``` + +--- + +## Phase E — Shell Redesign (Task 12) + +### Task 12: AppSidebar — двухтоновый shell, group-labels, badges, ⌘K stub, active marker + +**Files:** + +- Modify: `app/resources/js/components/layout/AppSidebar.vue` +- Test: `app/tests/Frontend/AppSidebarRedesign.spec.ts` + +- [ ] **Step 1: Write the failing test** + +```typescript +// app/tests/Frontend/AppSidebarRedesign.spec.ts +import { describe, it, expect, beforeEach } from 'vitest'; +import { mount, type VueWrapper } from '@vue/test-utils'; +import { createMemoryHistory, createRouter, type Router } from 'vue-router'; +import { createPinia, setActivePinia } from 'pinia'; +import { createVuetify } from 'vuetify'; +import AppSidebar from '../../resources/js/components/layout/AppSidebar.vue'; + +function setup(initialRoute = '/deals'): { wrapper: VueWrapper; router: Router } { + setActivePinia(createPinia()); + const router = createRouter({ + history: createMemoryHistory(), + routes: [ + { path: '/dashboard', component: { template: '
' } }, + { path: '/deals', component: { template: '
' } }, + { path: '/kanban', component: { template: '
' } }, + ], + }); + router.push(initialRoute); + const vuetify = createVuetify(); + const wrapper = mount(AppSidebar, { + global: { + plugins: [router, vuetify], + stubs: { RouterLink: true }, + }, + }); + return { wrapper, router }; +} + +describe('AppSidebar — redesigned shell', () => { + beforeEach(() => localStorage.clear()); + + it('has cmdk stub at top of sidebar', () => { + const { wrapper } = setup(); + expect(wrapper.find('.ld-cmdk-stub').exists()).toBe(true); + expect(wrapper.find('.ld-cmdk-stub').text()).toMatch(/Поиск|команды/i); + }); + + it('renders 3 nav-groups with eyebrow labels', () => { + const { wrapper } = setup(); + const eyebrows = wrapper.findAll('.ld-nav-group__eyebrow'); + expect(eyebrows.length).toBeGreaterThanOrEqual(3); + }); + + it('badge uses JetBrains Mono class', async () => { + const { wrapper } = setup(); + const badges = wrapper.findAll('.ld-nav-item__badge'); + expect(badges.length).toBeGreaterThan(0); + const styleSheet = badges[0].attributes('class') ?? ''; + expect(styleSheet).toMatch(/ld-mono|nav-item__badge/); + }); + + it('active nav-item has marker pseudo-element class', async () => { + const { wrapper } = setup('/deals'); + const items = wrapper.findAll('.ld-nav-item'); + const active = items.find((el) => el.classes().includes('ld-nav-item--active')); + expect(active).toBeDefined(); + }); +}); +``` + +- [ ] **Step 2: Run test, expect FAIL** + +Run: `npm run test:vue -- AppSidebarRedesign.spec.ts` + +- [ ] **Step 3: Update AppSidebar.vue** + +Read the full current file first via Read tool (≤200 lines). Then rewrite preserving: + +- defineModel for drawerOpen +- useRoute / useRemindersStore composables +- navGroups computed +- count-resolution logic + +Replace the entire `