# 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 `<Transition>` + 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<string, unknown>` или конкретные интерфейсы.
- 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<string, Record<string, unknown>> } }).defaults.value.VBtn?.variant).toBe('flat');
    });

    it('VCard defaults to rounded lg + variant flat + border', () => {
        const card = (vuetify as unknown as { defaults: { value: Record<string, Record<string, unknown>> } }).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<string, Record<string, unknown>> } }).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<StatusPillSlug, PillStyle> = {
    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<number>;
    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<number>, 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<Density>;
    rowHeight: ComputedRef<number>;
    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<Density>(loadInitial());

    const rowHeight = computed<number>(() => (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
<!-- app/resources/js/components/ui/StatusPill.vue -->
<script setup lang="ts">
import { computed } from 'vue';
import { useStatusPill } from '../../composables/useStatusPill';

const props = defineProps<{
    slug: string;
    label?: string;
}>();

const style = computed<Record<string, string>>(() => {
    const s = useStatusPill(props.slug);
    const css: Record<string, string> = {
        background: s.bg,
        color: s.color,
    };
    if (s.fontWeight) css['font-weight'] = String(s.fontWeight);
    if (s.textDecoration) css['text-decoration'] = s.textDecoration;
    return css;
});
</script>

<template>
    <span class="ld-status-pill" :style="style">{{ label ?? slug }}</span>
</template>

<style scoped>
.ld-status-pill {
    display: inline-flex;
    align-items: center;
    padding: 3px 9px;
    border-radius: var(--radius-full);
    font-size: 11px;
    font-weight: 500;
    transition: background 300ms cubic-bezier(0.16, 1, 0.3, 1),
                color 300ms cubic-bezier(0.16, 1, 0.3, 1);
}
</style>
```

- [ ] **Step 4: Histoire story**

```vue
<!-- app/resources/js/components/ui/StatusPill.story.vue -->
<script setup lang="ts">
import StatusPill from './StatusPill.vue';
import { STATUS_PILL_SLUGS } from '../../composables/useStatusPill';

const labelMap: Record<string, string> = {
    new: 'Новый',
    in_progress: 'В работе',
    callback: 'Перезвонить',
    quality: 'Качественный',
    meeting_set: 'Встреча',
    won: 'Продано',
    refund: 'Возврат',
    duplicate: 'Дубль',
    junk: 'Спам',
    no_answer: 'Нет ответа',
    cancelled: 'Отменено',
    closed: 'Закрыто',
    postponed: 'Отложено',
    archived: 'Архив',
};
</script>

<template>
    <Story title="UI/StatusPill" :layout="{ type: 'grid', width: 200 }">
        <Variant v-for="slug in STATUS_PILL_SLUGS" :key="slug" :title="slug">
            <StatusPill :slug="slug" :label="labelMap[slug]" />
        </Variant>
    </Story>
</template>
```

- [ ] **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
<!-- app/resources/js/components/ui/Kbd.vue -->
<script setup lang="ts">
defineProps<{ dark?: boolean }>();
</script>

<template>
    <kbd class="ld-kbd" :class="{ 'ld-kbd--dark': dark }">
        <slot />
    </kbd>
</template>

<style scoped>
.ld-kbd {
    font-family: 'JetBrains Mono', ui-monospace, monospace;
    font-size: 10px;
    padding: 1px 6px;
    border-radius: 4px;
    background: rgba(1, 32, 25, 0.06);
    color: var(--liderra-muted);
    border: 1px solid var(--liderra-line);
    line-height: 1.4;
}

.ld-kbd--dark {
    background: rgba(255, 255, 255, 0.08);
    color: rgba(232, 226, 212, 0.85);
    border-color: rgba(255, 255, 255, 0.10);
}
</style>
```

- [ ] **Step 4: Histoire story**

```vue
<!-- app/resources/js/components/ui/Kbd.story.vue -->
<script setup lang="ts">
import Kbd from './Kbd.vue';
</script>

<template>
    <Story title="UI/Kbd">
        <Variant title="Light"><Kbd>⌘K</Kbd> <Kbd>Esc</Kbd> <Kbd>/</Kbd></Variant>
        <Variant title="Dark (sidebar)">
            <div style="background:#012019;padding:14px;border-radius:8px"><Kbd dark>⌘K</Kbd></div>
        </Variant>
    </Story>
</template>
```

- [ ] **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
<!-- app/resources/js/components/ui/FilterChip.vue -->
<script setup lang="ts">
defineProps<{
    label: string;
    count?: number;
    active?: boolean;
}>();

defineEmits<{ click: [] }>();
</script>

<template>
    <button
        type="button"
        class="ld-filter-chip"
        :class="{ 'ld-filter-chip--active': active }"
        @click="$emit('click')"
    >
        <span>{{ label }}</span>
        <span v-if="count && count > 0" class="ld-filter-chip__count">{{ count }}</span>
        <span class="ld-filter-chip__caret">▾</span>
    </button>
</template>

<style scoped>
.ld-filter-chip {
    display: inline-flex;
    align-items: center;
    gap: 6px;
    padding: 7px 12px;
    border-radius: var(--radius-8);
    background: var(--liderra-surface);
    border: 1px solid var(--liderra-line);
    font-size: 12px;
    color: var(--liderra-noir);
    cursor: pointer;
    font-family: inherit;
    transition: border-color 200ms cubic-bezier(0.16, 1, 0.3, 1);
}
.ld-filter-chip:hover {
    border-color: var(--liderra-line-strong);
}
.ld-filter-chip--active {
    border-color: var(--liderra-teal);
    background: rgba(15, 110, 86, 0.06);
    color: var(--liderra-teal);
}
.ld-filter-chip__count {
    font-family: 'JetBrains Mono', ui-monospace, monospace;
    background: rgba(15, 110, 86, 0.12);
    color: var(--liderra-teal);
    padding: 1px 6px;
    border-radius: 4px;
    font-size: 10px;
}
.ld-filter-chip__caret {
    font-size: 9px;
    opacity: 0.6;
}
</style>
```

- [ ] **Step 4: Histoire story**

```vue
<!-- app/resources/js/components/ui/FilterChip.story.vue -->
<script setup lang="ts">
import FilterChip from './FilterChip.vue';
</script>

<template>
    <Story title="UI/FilterChip">
        <Variant title="Default">
            <FilterChip label="Статус" />
            <FilterChip label="Проект" :count="2" />
            <FilterChip label="Менеджер" :active="true" :count="3" />
        </Variant>
    </Story>
</template>
```

- [ ] **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
<!-- app/resources/js/components/ui/DensityToggle.vue -->
<script setup lang="ts">
import { useDensity, type Density } from '../../composables/useDensity';

const { density, setDensity } = useDensity();

const emit = defineEmits<{ change: [Density] }>();

function pick(d: Density): void {
    setDensity(d);
    emit('change', d);
}
</script>

<template>
    <div class="ld-density-toggle" role="group" aria-label="Плотность таблицы">
        <button
            type="button"
            class="ld-density-toggle__btn"
            :class="{ 'ld-density-toggle__btn--active': density === 'compact' }"
            @click="pick('compact')"
        >
            Компакт
        </button>
        <button
            type="button"
            class="ld-density-toggle__btn"
            :class="{ 'ld-density-toggle__btn--active': density === 'comfortable' }"
            @click="pick('comfortable')"
        >
            Комфорт
        </button>
    </div>
</template>

<style scoped>
.ld-density-toggle {
    display: inline-flex;
    background: var(--liderra-surface);
    border: 1px solid var(--liderra-line);
    border-radius: var(--radius-8);
    padding: 2px;
}
.ld-density-toggle__btn {
    background: transparent;
    border: none;
    padding: 5px 10px;
    border-radius: var(--radius-6);
    font-size: 11px;
    color: var(--liderra-muted);
    cursor: pointer;
    font-family: inherit;
    transition: background 200ms cubic-bezier(0.16, 1, 0.3, 1),
                color 200ms cubic-bezier(0.16, 1, 0.3, 1);
}
.ld-density-toggle__btn--active {
    background: rgba(1, 32, 25, 0.08);
    color: var(--liderra-noir);
}
</style>
```

- [ ] **Step 4: Histoire story**

```vue
<!-- app/resources/js/components/ui/DensityToggle.story.vue -->
<script setup lang="ts">
import DensityToggle from './DensityToggle.vue';
</script>

<template>
    <Story title="UI/DensityToggle">
        <Variant title="Default"><DensityToggle /></Variant>
    </Story>
</template>
```

- [ ] **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: '<div />' } },
            { path: '/deals', component: { template: '<div />' } },
            { path: '/kanban', component: { template: '<div />' } },
        ],
    });
    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 `<template>` section + `<style scoped>` with this structure (keep `<script setup>` block — extend it, don't replace):

```vue
<template>
    <aside class="ld-sidebar" :data-open="drawerOpen">
        <div class="ld-sidebar__brand">
            <span class="ld-sidebar__brand-name">Лидерра<span class="ld-sidebar__brand-dot">.</span></span>
        </div>

        <div class="ld-cmdk-stub" role="button" tabindex="0">
            <span class="ld-cmdk-stub__placeholder">Поиск, команды…</span>
            <Kbd dark>⌘K</Kbd>
        </div>

        <nav class="ld-sidebar__nav">
            <div v-for="(group, gi) in navGroups" :key="gi" class="ld-nav-group">
                <div class="ld-nav-group__eyebrow">{{ group.eyebrow }}</div>
                <RouterLink
                    v-for="item in group.items"
                    :key="item.to"
                    :to="item.to"
                    class="ld-nav-item"
                    :class="{ 'ld-nav-item--active': route.path === item.to }"
                >
                    <span class="ld-nav-item__title">{{ item.title }}</span>
                    <span
                        v-if="resolveCount(item) > 0"
                        class="ld-nav-item__badge ld-mono"
                    >{{ resolveCount(item) }}</span>
                </RouterLink>
            </div>
        </nav>
    </aside>
</template>
```

Add to `<script setup>` (preserve existing imports + computed):

```typescript
import Kbd from '../ui/Kbd.vue';

function resolveCount(item: NavItem): number {
    if (item.countKey === 'reminders') return reminders.dueCount;
    return item.count ?? 0;
}
```

Replace `<style scoped>` with:

```css
<style scoped>
.ld-sidebar {
    background: linear-gradient(180deg, var(--liderra-noir) 0%, #04261E 100%);
    color: #E8E2D4;
    padding: 20px 14px;
    width: 232px;
    min-height: 100vh;
    display: flex;
    flex-direction: column;
}

.ld-sidebar__brand {
    font-size: 15px;
    font-weight: 600;
    letter-spacing: -0.01em;
    padding: 0 8px;
    margin-bottom: 22px;
}
.ld-sidebar__brand-dot {
    color: var(--liderra-teal);
}

.ld-cmdk-stub {
    display: flex;
    align-items: center;
    justify-content: space-between;
    background: rgba(255, 255, 255, 0.06);
    border: 1px solid rgba(255, 255, 255, 0.08);
    padding: 8px 11px;
    border-radius: var(--radius-8);
    font-size: 12px;
    color: #9B9484;
    margin-bottom: 18px;
    cursor: pointer;
    transition: background 200ms cubic-bezier(0.16, 1, 0.3, 1);
}
.ld-cmdk-stub:hover {
    background: rgba(255, 255, 255, 0.10);
}

.ld-sidebar__nav { flex: 1; }

.ld-nav-group { margin-bottom: 6px; }
.ld-nav-group__eyebrow {
    font-size: 10px;
    text-transform: uppercase;
    letter-spacing: 0.14em;
    color: #6B7470;
    margin: 14px 8px 4px;
    font-weight: 500;
}

.ld-nav-item {
    display: flex;
    align-items: center;
    gap: 10px;
    padding: 7px 10px;
    border-radius: var(--radius-6);
    font-size: 13px;
    color: #B8B0A0;
    text-decoration: none;
    position: relative;
    transition: color 200ms cubic-bezier(0.16, 1, 0.3, 1),
                background 200ms cubic-bezier(0.16, 1, 0.3, 1);
    margin-bottom: 1px;
}
.ld-nav-item:hover {
    color: #E8E2D4;
    background: rgba(255, 255, 255, 0.04);
}
.ld-nav-item--active {
    color: var(--liderra-ivory);
    background: rgba(15, 110, 86, 0.22);
}
.ld-nav-item--active::before {
    content: '';
    position: absolute;
    left: 0;
    top: 6px;
    bottom: 6px;
    width: 2px;
    background: var(--liderra-teal);
    border-radius: 2px;
    transform-origin: center;
    animation: ld-marker-grow 250ms cubic-bezier(0.16, 1, 0.3, 1);
}
@keyframes ld-marker-grow {
    from { transform: scaleY(0); }
    to { transform: scaleY(1); }
}

.ld-nav-item__title { flex: 1; }

.ld-nav-item__badge {
    font-size: 10px;
    background: rgba(255, 255, 255, 0.08);
    padding: 1px 6px;
    border-radius: 4px;
    color: #B8B0A0;
    font-family: 'JetBrains Mono', ui-monospace, monospace;
    font-feature-settings: 'tnum' 1;
}
.ld-nav-item--active .ld-nav-item__badge {
    background: rgba(255, 255, 255, 0.10);
    color: var(--liderra-ivory);
}
</style>
```

- [ ] **Step 4: Run test, expect PASS**

Run: `npm run test:vue -- AppSidebarRedesign.spec.ts`

- [ ] **Step 5: Visual smoke — Histoire build**

Run: `npm run story:build 2>&1 | tail -20`
Expected: no errors. If AppSidebar.story.vue exists (it does per memory), the story should rebuild successfully.

- [ ] **Step 6: Manual visual verification (dev server)**

Open `http://127.0.0.1:8000/dashboard` (предполагая dev-сервер запущен из памяти сессии). Убедиться:

- Sidebar теало-нуар с градиентом
- ⌘K stub видим
- 3 nav-group метки
- Активный пункт имеет левую Teal-полоску

(Если sandbox-режим не позволяет открыть браузер — пропустить шаг и положиться на тесты + Histoire.)

- [ ] **Step 7: Lint + commit**

```bash
cd app && npm run lint:vue && npm run format
git add app/resources/js/components/layout/AppSidebar.vue app/tests/Frontend/AppSidebarRedesign.spec.ts
git commit -m "feat(redesign): Task 12 — AppSidebar двухтоновый shell + ⌘K stub + active marker (motion #7)"
```

---

## Phase F — Page Transitions (Task 13)

### Task 13: Page transition wiring (View Transitions API + CSS fallback)

**Files:**

- Modify: `app/resources/js/router/index.ts`
- Modify: `app/resources/js/components/AppShell.vue` (или App.vue — где `<RouterView>`)
- Test: `app/tests/Frontend/pageTransition.spec.ts`

- [ ] **Step 1: Write the failing test**

```typescript
// app/tests/Frontend/pageTransition.spec.ts
import { describe, it, expect } from 'vitest';
import { router } from '../../resources/js/router';

describe('Router — page transition meta', () => {
    it('every authenticated route declares transition meta', () => {
        const routes = router.getRoutes();
        const auth = routes.filter((r) => r.meta?.layout === 'app');
        expect(auth.length).toBeGreaterThan(0);
        for (const r of auth) {
            expect(r.meta?.transition, `${String(r.name)} missing meta.transition`).toBe('ld-route-fadeup');
        }
    });
});
```

- [ ] **Step 2: Run test, expect FAIL**

Run: `npm run test:vue -- pageTransition.spec.ts`

- [ ] **Step 3: Update router/index.ts**

Read current `app/resources/js/router/index.ts`. For every route с `meta.layout === 'app'`, add `transition: 'ld-route-fadeup'` в `meta`. Не трогать auth/error routes.

Example pattern:

```typescript
{
    path: '/deals',
    name: 'deals',
    component: () => import('../views/DealsView.vue'),
    meta: { layout: 'app', requiresAuth: true, transition: 'ld-route-fadeup', devIndex: 3 },
}
```

- [ ] **Step 4: Update AppShell.vue (или whichever file wraps RouterView)**

Read the current wrapper file. Replace `<RouterView />` with:

```vue
<RouterView v-slot="{ Component, route }">
    <Transition :name="(route.meta.transition as string) ?? 'ld-route-fadeup'" mode="out-in">
        <component :is="Component" :key="route.fullPath" />
    </Transition>
</RouterView>
```

Note: View Transitions API через `document.startViewTransition` — отдельный путь; CSS-fallback через Vue Transition покрывает Firefox/Safari<18. Спецификация говорит: достаточно CSS fallback в Iteration 1.

- [ ] **Step 5: Run test, expect PASS**

Run: `npm run test:vue -- pageTransition.spec.ts`

- [ ] **Step 6: Full test suite smoke**

Run: `npm run test:vue`
Expected: все тесты остаются зелёными.

- [ ] **Step 7: Lint + commit**

```bash
cd app && npm run lint:vue && npm run format
git add app/resources/js/router/index.ts app/resources/js/components/AppShell.vue app/tests/Frontend/pageTransition.spec.ts
git commit -m "feat(redesign): Task 13 — page transition wiring (Vue Transition + CSS fadeup, motion #6)"
```

---

## Phase G — View Applications (Tasks 14–17)

### Task 14: DashboardView — KPI count-up (motion #1) + live pulse

**Files:**

- Modify: `app/resources/js/views/DashboardView.vue`
- Test: `app/tests/Frontend/DashboardCountUp.spec.ts`

- [ ] **Step 1: Write the failing test**

```typescript
// app/tests/Frontend/DashboardCountUp.spec.ts
import { describe, it, expect, beforeEach, vi } from 'vitest';
import { mount, flushPromises } from '@vue/test-utils';
import { createPinia, setActivePinia } from 'pinia';
import { createVuetify } from 'vuetify';
import DashboardView from '../../resources/js/views/DashboardView.vue';

describe('DashboardView — KPI count-up', () => {
    beforeEach(() => {
        setActivePinia(createPinia());
        Object.defineProperty(window, 'matchMedia', {
            writable: true,
            configurable: true,
            value: vi.fn().mockReturnValue({ matches: false, addEventListener: vi.fn(), removeEventListener: vi.fn() }),
        });
    });

    it('renders KPI strip with JetBrains Mono number', async () => {
        const w = mount(DashboardView, {
            global: { plugins: [createVuetify()], stubs: { RouterLink: true } },
        });
        await flushPromises();
        const kpis = w.findAll('.ld-kpi__value');
        expect(kpis.length).toBeGreaterThan(0);
        expect(kpis[0].classes()).toContain('ld-mono');
    });

    it('shows live pulse indicator', () => {
        const w = mount(DashboardView, {
            global: { plugins: [createVuetify()], stubs: { RouterLink: true } },
        });
        expect(w.find('.ld-pulse').exists()).toBe(true);
    });
});
```

- [ ] **Step 2: Run test, expect FAIL**

Run: `npm run test:vue -- DashboardCountUp.spec.ts`

- [ ] **Step 3: Update DashboardView.vue**

Read full current file via Read tool. Identify KPI block(s). Wrap KPI values with new structure:

```vue
<script setup lang="ts">
// Сохрани все существующие импорты и логику
import { computed, onMounted, ref } from 'vue';
import { useCountUp } from '../composables/useCountUp';

// Для каждого KPI value:
const leadsTodayTarget = ref(0); // или из store
const { display: leadsTodayDisplay, start: startLeads } = useCountUp(leadsTodayTarget, { duration: 600 });

onMounted(() => {
    // когда данные пришли из API:
    leadsTodayTarget.value = 47; // example, заменить на store-getter
    startLeads();
});
</script>

<template>
    <!-- ... сохранить остальной layout ... -->
    <div class="ld-kpi-strip">
        <div class="ld-kpi">
            <div class="ld-kpi__label ld-label">Лидов сегодня</div>
            <div class="ld-kpi__value ld-mono">{{ leadsTodayDisplay }}</div>
        </div>
        <!-- остальные KPI аналогично -->
    </div>
    <div class="ld-meta">
        <span class="ld-pulse" aria-hidden="true"></span>
        <span>Live · обновлено только что</span>
    </div>
</template>

<style scoped>
.ld-kpi-strip {
    display: grid;
    grid-template-columns: repeat(5, 1fr);
    gap: 1px;
    background: var(--liderra-line);
    margin-bottom: 16px;
}
.ld-kpi {
    background: var(--liderra-ivory);
    padding: 14px 16px;
}
.ld-kpi__label { margin-bottom: 4px; }
.ld-kpi__value {
    font-size: 22px;
    font-weight: 500;
    letter-spacing: -0.02em;
    line-height: 1;
}
.ld-meta {
    display: flex;
    align-items: center;
    gap: 6px;
    font-size: 12px;
    color: var(--liderra-muted);
}
</style>
```

- [ ] **Step 4: Run test, expect PASS**

Run: `npm run test:vue -- DashboardCountUp.spec.ts`

- [ ] **Step 5: Full Vitest smoke**

Run: `npm run test:vue`
Expected: все остаются зелёными.

- [ ] **Step 6: Lint + commit**

```bash
cd app && npm run lint:vue && npm run format
git add app/resources/js/views/DashboardView.vue app/tests/Frontend/DashboardCountUp.spec.ts
git commit -m "feat(redesign): Task 14 — DashboardView KPI count-up (motion #1) + live pulse"
```

---

### Task 15: DealsView — filterbar + density toggle + table stagger + hover lift + StatusPill

**Files:**

- Modify: `app/resources/js/views/DealsView.vue`
- Test: `app/tests/Frontend/DealsViewRedesign.spec.ts`

- [ ] **Step 1: Write the failing test**

```typescript
// app/tests/Frontend/DealsViewRedesign.spec.ts
import { describe, it, expect, beforeEach } from 'vitest';
import { mount, flushPromises } from '@vue/test-utils';
import { createPinia, setActivePinia } from 'pinia';
import { createVuetify } from 'vuetify';
import { createMemoryHistory, createRouter } from 'vue-router';
import DealsView from '../../resources/js/views/DealsView.vue';

function setup() {
    setActivePinia(createPinia());
    const router = createRouter({ history: createMemoryHistory(), routes: [{ path: '/deals', component: DealsView }] });
    router.push('/deals');
    return mount(DealsView, {
        global: {
            plugins: [router, createVuetify()],
            stubs: { RouterLink: true, VDataTable: true },
        },
    });
}

describe('DealsView — redesigned', () => {
    beforeEach(() => localStorage.clear());

    it('renders filterbar with at least 3 FilterChips', async () => {
        const w = setup();
        await flushPromises();
        const chips = w.findAll('.ld-filter-chip');
        expect(chips.length).toBeGreaterThanOrEqual(3);
    });

    it('renders DensityToggle in filterbar', async () => {
        const w = setup();
        await flushPromises();
        expect(w.find('.ld-density-toggle').exists()).toBe(true);
    });

    it('row uses StatusPill component for status column', async () => {
        const w = setup();
        await flushPromises();
        // After data load — at least one ld-status-pill should be present
        // (если stub VDataTable — test проверяет наличие компонента в template, не в render)
        expect(w.html()).toMatch(/ld-status-pill|StatusPill/);
    });

    it('applies ld-hover-lift utility class to table container or row wrapper', async () => {
        const w = setup();
        await flushPromises();
        expect(w.html()).toMatch(/ld-hover-lift|hover-lift/);
    });

    it('applies ld-stagger-row class to deal rows (motion #2)', async () => {
        const w = setup();
        await flushPromises();
        expect(w.html()).toMatch(/ld-stagger-row/);
    });
});
```

- [ ] **Step 2: Run test, expect FAIL**

Run: `npm run test:vue -- DealsViewRedesign.spec.ts`

- [ ] **Step 3: Update DealsView.vue**

Read full current file. Replace existing filter UI with:

```vue
<script setup lang="ts">
// Сохрани все существующие импорты + dealsStore + filter logic
import FilterChip from '../components/ui/FilterChip.vue';
import DensityToggle from '../components/ui/DensityToggle.vue';
import StatusPill from '../components/ui/StatusPill.vue';
import { useDensity } from '../composables/useDensity';

const { rowHeight } = useDensity();

const filters = ref({
    status: [] as string[],
    project: [] as string[],
    manager: [] as string[],
});

const statusFilterCount = computed(() => filters.value.status.length);
const projectFilterCount = computed(() => filters.value.project.length);
const managerFilterCount = computed(() => filters.value.manager.length);

// Реальный фильтр-flow остаётся, новые UI чипы открывают существующий VMenu / VSelect.
</script>

<template>
    <!-- ... сохранить page-head ... -->
    <div class="ld-filterbar">
        <v-text-field
            v-model="searchQuery"
            placeholder="Поиск по телефону, имени, ID…"
            density="comfortable"
            hide-details
            class="ld-filterbar__search"
        />
        <FilterChip label="Статус" :count="statusFilterCount" :active="statusFilterCount > 0" @click="openStatusMenu" />
        <FilterChip label="Проект" :count="projectFilterCount" :active="projectFilterCount > 0" @click="openProjectMenu" />
        <FilterChip label="Менеджер" :count="managerFilterCount" :active="managerFilterCount > 0" @click="openManagerMenu" />
        <DensityToggle class="ld-filterbar__density" />
    </div>

    <v-data-table
        :items="deals"
        :headers="headers"
        :item-value="'id'"
        :row-props="() => ({ class: 'ld-hover-lift ld-stagger-row', style: { height: `${rowHeight}px` } })"
        class="ld-deals-table"
    >
        <template #item.status="{ item }">
            <StatusPill :slug="item.status" :label="statusLabels[item.status] ?? item.status" />
        </template>
        <template #item.phone="{ item }">
            <span class="ld-mono">{{ item.phone }}</span>
        </template>
        <template #item.received_at="{ item }">
            <span class="ld-mono-s">{{ formatTime(item.received_at) }}</span>
        </template>
    </v-data-table>
</template>

<style scoped>
.ld-filterbar {
    display: flex;
    gap: 10px;
    align-items: center;
    flex-wrap: wrap;
    padding: 16px var(--space-8);
    border-top: 1px solid var(--liderra-line);
    border-bottom: 1px solid var(--liderra-line);
}
.ld-filterbar__search { max-width: 340px; flex: 1; }
.ld-filterbar__density { margin-left: auto; }
</style>
```

(Если в существующем DealsView функции `openStatusMenu` и т.п. отсутствуют — реализовать `ref<boolean>` + VMenu wrapper. Если уже используется VSelect — оставить VSelect, FilterChip кладётся как `activator slot`. Контракт: чип отображает выбранное состояние/счётчик, реальное меню — существующий механизм.)

- [ ] **Step 4: Run test, expect PASS**

Run: `npm run test:vue -- DealsViewRedesign.spec.ts`

- [ ] **Step 5: Full Vitest smoke**

Run: `npm run test:vue`

- [ ] **Step 6: Lint + commit**

```bash
cd app && npm run lint:vue && npm run format
git add app/resources/js/views/DealsView.vue app/tests/Frontend/DealsViewRedesign.spec.ts
git commit -m "feat(redesign): Task 15 — DealsView filterbar + density + StatusPill + hover lift (motion #2,#4)"
```

---

### Task 16: KanbanView — StatusPill + hover lift на карточках

**Files:**

- Modify: `app/resources/js/views/KanbanView.vue`
- Test: `app/tests/Frontend/KanbanRedesign.spec.ts`

- [ ] **Step 1: Write the failing test**

```typescript
// app/tests/Frontend/KanbanRedesign.spec.ts
import { describe, it, expect, beforeEach } from 'vitest';
import { mount, flushPromises } from '@vue/test-utils';
import { createPinia, setActivePinia } from 'pinia';
import { createVuetify } from 'vuetify';
import { createMemoryHistory, createRouter } from 'vue-router';
import KanbanView from '../../resources/js/views/KanbanView.vue';

describe('KanbanView — redesigned', () => {
    beforeEach(() => setActivePinia(createPinia()));

    it('card containers have ld-hover-lift class', async () => {
        const router = createRouter({ history: createMemoryHistory(), routes: [{ path: '/', component: KanbanView }] });
        router.push('/');
        const w = mount(KanbanView, { global: { plugins: [router, createVuetify()], stubs: { RouterLink: true } } });
        await flushPromises();
        expect(w.html()).toMatch(/ld-hover-lift/);
    });

    it('column headers use ld-label class', async () => {
        const router = createRouter({ history: createMemoryHistory(), routes: [{ path: '/', component: KanbanView }] });
        router.push('/');
        const w = mount(KanbanView, { global: { plugins: [router, createVuetify()], stubs: { RouterLink: true } } });
        await flushPromises();
        expect(w.html()).toMatch(/ld-label/);
    });
});
```

- [ ] **Step 2: Run test, expect FAIL**

Run: `npm run test:vue -- KanbanRedesign.spec.ts`

- [ ] **Step 3: Update KanbanView.vue**

Прочитай файл (Read tool). Внеси точечные правки в template:

- На каждом `<v-card>` (карточка сделки): добавить `class="ld-hover-lift"`.
- На каждом заголовке колонки: добавить `class="ld-label"`.
- Если в карточке выводится статус через text — заменить на `<StatusPill :slug="deal.status" :label="..." />` (импорт StatusPill).

- [ ] **Step 4: Run test, expect PASS**

Run: `npm run test:vue -- KanbanRedesign.spec.ts`

- [ ] **Step 5: Lint + commit**

```bash
cd app && npm run lint:vue && npm run format
git add app/resources/js/views/KanbanView.vue app/tests/Frontend/KanbanRedesign.spec.ts
git commit -m "feat(redesign): Task 16 — KanbanView StatusPill + hover lift (motion #4)"
```

---

### Task 17: ProjectsView — карточки + hover lift на токенах

**Files:**

- Modify: `app/resources/js/views/ProjectsView.vue` (или соответствующий компонент карточки)
- Modify: `app/resources/js/components/projects/ProjectCard.vue`
- Test: `app/tests/Frontend/ProjectCardRedesign.spec.ts`

- [ ] **Step 1: Write the failing test**

```typescript
// app/tests/Frontend/ProjectCardRedesign.spec.ts
import { describe, it, expect } from 'vitest';
import { mount } from '@vue/test-utils';
import { createVuetify } from 'vuetify';
import ProjectCard from '../../resources/js/components/projects/ProjectCard.vue';

const sample = {
    id: 1,
    name: 'Окна СПб',
    tag: 'site',
    signal_type: 'site',
    is_active: true,
    daily_limit_target: 50,
    delivered_today: 12,
    archived_at: null,
};

describe('ProjectCard — redesigned', () => {
    it('root has ld-hover-lift class', () => {
        const w = mount(ProjectCard, {
            props: { project: sample },
            global: { plugins: [createVuetify()], stubs: { RouterLink: true } },
        });
        expect(w.classes()).toContain('ld-hover-lift');
    });

    it('numeric counter uses ld-mono class', () => {
        const w = mount(ProjectCard, {
            props: { project: sample },
            global: { plugins: [createVuetify()], stubs: { RouterLink: true } },
        });
        expect(w.html()).toMatch(/ld-mono/);
    });
});
```

- [ ] **Step 2: Run test, expect FAIL**

Run: `npm run test:vue -- ProjectCardRedesign.spec.ts`

- [ ] **Step 3: Update ProjectCard.vue**

Прочитай файл. Добавь `class="ld-hover-lift"` на корневой VCard. Numeric badges (delivered_today / daily_limit_target) — обернуть в `<span class="ld-mono">{{ ... }}</span>`. Без других визуальных правок (Plan 5 frontend уже задаёт layout — мы только надеваем токены).

- [ ] **Step 4: Run test, expect PASS**

Run: `npm run test:vue -- ProjectCardRedesign.spec.ts`

- [ ] **Step 5: Lint + commit**

```bash
cd app && npm run lint:vue && npm run format
git add app/resources/js/components/projects/ProjectCard.vue app/tests/Frontend/ProjectCardRedesign.spec.ts
git commit -m "feat(redesign): Task 17 — ProjectCard tokens (hover lift + JetBrains Mono numerics)"
```

---

## Phase H — Final verification

### Task 18: Acceptance verification + Pa11y CI

**Files:**

- No code changes; verification commands + spec checklist sweep.

- [ ] **Step 1: Full Vitest suite зелёный**

Run: `npm run test:vue`
Expected: все unit/component тесты PASS, ноль failed.

- [ ] **Step 2: ESLint + Prettier zero-warning**

```bash
cd app && npm run lint:vue && npm run format:check
```

Expected: 0 warnings, 0 unformatted files.

- [ ] **Step 3: Type-check (vue-tsc)**

```bash
cd app && npm run type-check
```

Expected: 0 type errors.

- [ ] **Step 4: Histoire build smoke**

```bash
cd app && npm run story:build 2>&1 | tail -20
```

Expected: 4 новых stories (StatusPill, Kbd, FilterChip, DensityToggle) discovered, build OK.

- [ ] **Step 5: Pa11y CI run на 4 ключевых view'ах**

Скрипт Pa11y запускается per-сценарию из `package.json` через `npm run a11y` (см. CLAUDE.md §4). Если конфиг покрывает только статические HTML — расширить под Vue dev-сервер: запустить `php artisan serve` + `npm run dev`, прогнать Pa11y на:

- `http://127.0.0.1:8000/dashboard`
- `http://127.0.0.1:8000/deals`
- `http://127.0.0.1:8000/kanban`
- `http://127.0.0.1:8000/projects`

(если a11y-config требует доработки под SPA — задача создаётся отдельно в Iteration 2; на Iteration 1 достаточно ручной проверки axe DevTools в Chrome.)

Expected: 0 violations на 4 ключевых view'ах.

- [ ] **Step 6: Spec acceptance checklist sweep**

Открыть `docs/superpowers/specs/2026-05-12-portal-redesign-quiet-luxury-design.md` §13 Acceptance criteria. Пройти по 10 пунктам, отметить выполненные (галочкой в файле). При наличии открытых — создать дополнительную задачу.

- [ ] **Step 7: Финальный коммит acceptance-checklist**

```bash
cd app && cd ..
git add docs/superpowers/specs/2026-05-12-portal-redesign-quiet-luxury-design.md
git commit -m "docs(specs): Task 18 — portal redesign Iteration 1 acceptance checklist ✓"
```

---

## Notes for executor

- **Order of execution:** строго по Task 1 → Task 18. Foundation-файлы (Tasks 1–3) обязательно идут первыми — все последующие компоненты от них зависят.
- **TDD дисциплина:** каждая задача — Red → Green → Commit. Не пишите production-код без падающего теста. Если тест слишком сложно написать на этапе — это сигнал, что архитектурно decomposition неправильный, переоткройте задачу.
- **`@/` alias не настроен:** используйте относительные импорты (`../composables/...`). Memory feedback_environment квирк 52.
- **VDialog в jsdom:** при необходимости тестировать модалки — `global.stubs.VDialog = true` + manual slot rendering. Memory квирк 54.
- **Vitest и matchMedia:** mock `window.matchMedia` для тестов, использующих `prefers-reduced-motion`. См. Task 6 example.
- **Histoire `setupFile`:** при добавлении новых story.vue файлов перезапустить `npm run story` (HMR не подхватывает новые файлы).
- **Не трогать backend.** Все 28 view'ов сохраняют существующие routes/store/API; меняется только визуальный слой. Любая правка в `app/Http/Controllers`, `app/Models`, `db/migrations`, `db/schema.sql` — нарушение scope, переоткройте задачу.
- **Vuetify defaults vs scoped:** если задача требует визуального change, проверь сначала Vuetify default — может быть, override в `vuetify.ts` решает; только если default не покрывает — пиши scoped CSS.
- **Commits:** один task = один коммит. Минимум 17 коммитов.
- **Финальная цель ветки `plan5-frontend-projects`:** после Task 18 — готовность к squash/merge в main отдельным PR. Этот redesign-pass и Plan 5 функционально независимы (redesign — visual layer over существующих view'ов); порядок merge — на усмотрение заказчика.
