diff --git a/docs/superpowers/plans/2026-07-03-phase1c-react-spa.md b/docs/superpowers/plans/2026-07-03-phase1c-react-spa.md new file mode 100644 index 0000000..dece3db --- /dev/null +++ b/docs/superpowers/plans/2026-07-03-phase1c-react-spa.md @@ -0,0 +1,854 @@ +# Phase 1C: React SPA — 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. UI-задачи ДОПОЛНИТЕЛЬНО используют superpowers:frontend-design для визуального качества. Steps use checkbox (`- [ ]`) syntax. + +**Goal:** Полноценный React SPA поверх готового REST API (1B): управление учётками/шаблонами/доменами, импорт зон, просмотр дифа с семантической подсветкой и применение с guard на удаление; в prod вшивается в Go-бинарь. + +**Architecture:** `web/` — Vite + React + TS. TanStack Query для server-state, типизированный fetch-клиент к `/api/v1/projects/{DEFAULT_PROJECT_ID}`. shadcn/ui + Tailwind, тёмная «technical console» эстетика. В dev — Vite proxy на Go API; в prod — `vite build` → `dist/` вшивается через `embed.FS` в `internal/web`, отдаётся `cmd/server` с SPA-fallback (API-роуты приоритетны). + +**Tech Stack:** Vite, React 18+, TypeScript, react-router-dom, @tanstack/react-query v5, Tailwind CSS v4 (`@tailwindcss/vite`), shadcn/ui, react-hook-form + zod, Vitest + React Testing Library. Пакетный менеджер — **npm**. + +## Global Constraints + +- Каталог фронта — `web/` в корне репозитория (Go-модуль `github.com/vasyakrg/dns-autoresolver`). +- `DEFAULT_PROJECT_ID = "00000000-0000-0000-0000-000000000002"` (seed default project; Фаза 1C без логина). +- API base: `/api/v1/projects/${DEFAULT_PROJECT_ID}`. В dev Vite проксирует `/api` → `http://localhost:8080`. +- Секрет учётки — только на вход (POST accounts); НИКОГДА не отображается обратно (API его и не возвращает). +- Apply: чекбокс prune **по умолчанию выключен**; включается только явным действием пользователя с визуальным предупреждением. Тело `POST apply`: `{applyUpdates:true, applyPrunes:}`. +- Диф-семантика и цвета (CSS-переменные): add=emerald, update=amber, delete/prune=rose, in-sync=muted, read-only=dimmed. +- Типографика: НЕ Inter/Roboto/system. UI-гротеск + моноширинный для DNS-данных/дифов. +- Эстетика: тёмная refined «technical console» (см. spec, секция «Фаза 1C»). UI-задачи используют skill frontend-design. +- Тесты: каждая задача с логикой завершается зелёным `npm run test` (Vitest) и коммитом. Go-задача — `go test`. +- Типы фронта (`api/types.ts`) зеркалят Go DTO из `internal/api` (Account/Template/Domain/Changeset/RecordView/ApplyRequest). + +--- + +### Task 1: Scaffold web/ (Vite + Tailwind + shadcn + router + тема) + +**Files:** +- Create: `web/` (Vite react-ts проект), `web/vite.config.ts`, `web/tsconfig*.json`, `web/components.json` +- Create: `web/src/index.css` (тема, шрифты, diff-переменные), `web/src/main.tsx`, `web/src/App.tsx` +- Create: `web/src/components/Layout.tsx` +- Modify: `.gitignore` (web/node_modules, web/dist) +- Create: `web/src/App.test.tsx` (smoke) + +**Interfaces:** +- Produces: рабочий dev-сервер (`npm run dev`) с proxy; alias `@`; тёмная тема; роутер-каркас со страницами-заглушками; Vitest настроен. + +- [ ] **Step 1: Создать Vite-проект и зависимости** + +```bash +cd /Users/vasyansk/Developers/MyProject/IaaC/Realmanual/dns-autoresolver +npm create vite@latest web -- --template react-ts +cd web +npm install +npm install -D @tailwindcss/vite tailwindcss +npm install react-router-dom @tanstack/react-query +npm install react-hook-form zod @hookform/resolvers +npm install -D vitest @testing-library/react @testing-library/jest-dom jsdom @testing-library/user-event +``` + +- [ ] **Step 2: Tailwind v4 + alias + proxy + vitest в конфиге** + +`web/src/index.css` (начало): +```css +@import "tailwindcss"; +``` + +`web/vite.config.ts`: +```ts +import { defineConfig } from "vite" +import react from "@vitejs/plugin-react" +import tailwindcss from "@tailwindcss/vite" +import path from "path" + +export default defineConfig({ + plugins: [react(), tailwindcss()], + resolve: { + alias: { "@": path.resolve(__dirname, "./src") }, + }, + server: { + proxy: { + "/api": { target: "http://localhost:8080", changeOrigin: true }, + }, + }, + test: { + environment: "jsdom", + globals: true, + setupFiles: "./src/test-setup.ts", + }, +}) +``` + +`web/src/test-setup.ts`: +```ts +import "@testing-library/jest-dom" +``` + +`web/tsconfig.json` — добавить в `compilerOptions`: +```json +{ + "compilerOptions": { + "baseUrl": ".", + "paths": { "@/*": ["./src/*"] } + } +} +``` +(и то же `baseUrl`/`paths` в `tsconfig.app.json`, чтобы редактор/сборка резолвили `@`.) + +- [ ] **Step 3: Инициализировать shadcn и добавить компоненты** + +```bash +npx shadcn@latest init # base color: slate; CSS variables: yes; dark mode готов +npx shadcn@latest add button table dialog input label form card badge checkbox sonner separator textarea select +``` + +- [ ] **Step 4: Тема «technical console» + шрифты + diff-переменные** + +Дополнить `web/src/index.css` (после shadcn-переменных): подключить характерные шрифты (не Inter/system) и mono, задать тёмную тему по умолчанию (`` в `index.html`) и diff-семантику: +```css +/* Шрифты через Fontsource (устанавливаются): напр. @fontsource-variable/hanken-grotesk и @fontsource-variable/ibm-plex-mono */ +:root { + --font-ui: "Hanken Grotesk Variable", ui-sans-serif, sans-serif; + --font-mono: "IBM Plex Mono", ui-monospace, monospace; + + --diff-add: oklch(0.72 0.15 155); /* emerald */ + --diff-update: oklch(0.80 0.14 85); /* amber */ + --diff-delete: oklch(0.68 0.19 20); /* rose */ + --diff-insync: oklch(0.55 0.02 260); /* muted */ + --diff-readonly: oklch(0.50 0.02 260); /* dimmed */ +} +body { font-family: var(--font-ui); } +.font-dns { font-family: var(--font-mono); } +``` +Установить шрифты: +```bash +npm install @fontsource-variable/hanken-grotesk @fontsource-variable/ibm-plex-mono +``` +и импортировать их в `web/src/main.tsx`. +Реализатор: подключить skill frontend-design и довести тёмную «technical console» тему (фон-атмосфера, границы, тени) — это центральное впечатление продукта. + +- [ ] **Step 5: main.tsx + App.tsx + Layout + роутер-каркас** + +`web/src/main.tsx`: +```tsx +import "@fontsource-variable/hanken-grotesk" +import "@fontsource-variable/ibm-plex-mono" +import "./index.css" +import React from "react" +import ReactDOM from "react-dom/client" +import { QueryClient, QueryClientProvider } from "@tanstack/react-query" +import { BrowserRouter } from "react-router-dom" +import { App } from "./App" + +const queryClient = new QueryClient() + +ReactDOM.createRoot(document.getElementById("root")!).render( + + + + + + + , +) +``` + +`web/src/App.tsx` — роуты со страницами-заглушками (реальные страницы — задачи 3-6): +```tsx +import { Routes, Route, Navigate } from "react-router-dom" +import { Layout } from "@/components/Layout" + +function Placeholder({ name }: { name: string }) { + return
{name}
+} + +export function App() { + return ( + + + } /> + } /> + } /> + } /> + } /> + + + ) +} +``` + +`web/src/components/Layout.tsx` — сайдбар/навигация (Domains/Accounts/Templates), тёмная console-эстетика (реализатор дорабатывает по frontend-design). + +- [ ] **Step 6: Smoke-тест + .gitignore** + +`.gitignore` (добавить): +``` +web/node_modules/ +web/dist/ +``` + +`web/src/App.test.tsx`: +```tsx +import { render, screen } from "@testing-library/react" +import { MemoryRouter } from "react-router-dom" +import { QueryClient, QueryClientProvider } from "@tanstack/react-query" +import { App } from "./App" + +test("renders navigation and redirects to domains", () => { + render( + + + + + , + ) + expect(screen.getByText("Domains")).toBeInTheDocument() +}) +``` + +Добавить в `web/package.json` scripts: `"test": "vitest run"`, `"test:watch": "vitest"`. + +- [ ] **Step 7: Проверки** + +Run: `cd web && npm run test` → PASS (smoke). +Run: `cd web && npx tsc --noEmit` → без ошибок типов. +Run: `cd web && npm run build` → `dist/` собирается. + +- [ ] **Step 8: Commit** + +```bash +git add web/ .gitignore +git commit -m "feat(web): scaffold Vite+React+TS, Tailwind v4, shadcn, router, тёмная console-тема" +``` + +--- + +### Task 2: API-клиент, типы и Query-хуки + +**Files:** +- Create: `web/src/lib/config.ts`, `web/src/api/types.ts`, `web/src/api/client.ts`, `web/src/api/client.test.ts` +- Create: `web/src/hooks/useApi.ts` + +**Interfaces:** +- Consumes: Go DTO из `internal/api` (Account/Template/Domain/Changeset/RecordView/ApplyRequest — сверить с `internal/api/dto.go`, `tenant_dto.go`) +- Produces: + - `DEFAULT_PROJECT_ID` + - типы `Account, Template, Domain, RecordView, ChangesetResponse, RecordDTO, ApplyRequest` + - `api` — объект с методами: `listAccounts/createAccount/deleteAccount`, `listTemplates/createTemplate/updateTemplate/deleteTemplate`, `listDomains/createDomain/deleteDomain/importZones/setDomainTemplate`, `checkDomain/applyDomain` + - хуки: `useAccounts, useCreateAccount, useDeleteAccount, useTemplates, ...`, `useDomains, useImportZones, useSetDomainTemplate, useCheckDomain, useApplyDomain` + +- [ ] **Step 1: Написать падающий тест клиента** + +`web/src/api/client.test.ts`: +```ts +import { describe, it, expect, vi, beforeEach } from "vitest" +import { api } from "./client" +import { DEFAULT_PROJECT_ID } from "@/lib/config" + +beforeEach(() => { vi.restoreAllMocks() }) + +function mockFetch(body: unknown, ok = true, status = 200) { + return vi.spyOn(globalThis, "fetch").mockResolvedValue({ + ok, status, + json: async () => body, + text: async () => JSON.stringify(body), + } as Response) +} + +describe("api client", () => { + it("lists accounts at project-scoped path", async () => { + const spy = mockFetch([{ id: "a1", provider: "selectel", comment: "" }]) + const accounts = await api.listAccounts() + expect(accounts).toHaveLength(1) + expect(spy).toHaveBeenCalledWith( + `/api/v1/projects/${DEFAULT_PROJECT_ID}/accounts`, + expect.objectContaining({ method: "GET" }), + ) + }) + + it("sends secret on account creation but path has no secret leakage in response typing", async () => { + const spy = mockFetch({ id: "a2", provider: "selectel", comment: "prod" }) + await api.createAccount({ provider: "selectel", secret: "TOKEN", comment: "prod" }) + const [, opts] = spy.mock.calls[0] + expect((opts as RequestInit).method).toBe("POST") + expect(String((opts as RequestInit).body)).toContain("TOKEN") + }) + + it("throws on non-ok response", async () => { + mockFetch({ error: "boom" }, false, 500) + await expect(api.listDomains()).rejects.toThrow() + }) + + it("applies with prune flag", async () => { + const spy = mockFetch({ updates: [], prunes: [], readOnly: [], inSyncCount: 0 }) + await api.applyDomain("d1", { applyUpdates: true, applyPrunes: true }) + const [url, opts] = spy.mock.calls[0] + expect(url).toContain("/domains/d1/apply") + expect(String((opts as RequestInit).body)).toContain("applyPrunes") + }) +}) +``` + +- [ ] **Step 2: Запустить — падает** + +Run: `cd web && npm run test -- client` → FAIL. + +- [ ] **Step 3: config + types + client** + +`web/src/lib/config.ts`: +```ts +export const DEFAULT_PROJECT_ID = "00000000-0000-0000-0000-000000000002" +export const API_BASE = `/api/v1/projects/${DEFAULT_PROJECT_ID}` +``` + +`web/src/api/types.ts` (зеркало Go DTO — сверить имена полей с internal/api): +```ts +export interface Account { id: string; provider: string; comment: string } +export interface CreateAccountInput { provider: string; secret: string; comment: string } + +export interface RecordDTO { type: string; name: string; ttl: number; values: string[] } +export interface Template { id: string; name: string; records: RecordDTO[] } +export interface CreateTemplateInput { name: string; records: RecordDTO[] } + +export interface Domain { + id: string + providerAccountId: string + zoneName: string + zoneId: string + templateId: string | null +} +export interface CreateDomainInput { + providerAccountId: string + zoneName: string + zoneId: string + templateId?: string | null +} + +export interface RecordView { + kind: string // add | update | delete | in_sync + type: string + name: string + desired?: string[] + actual?: string[] + readOnly: boolean +} +export interface ChangesetResponse { + updates: RecordView[] + prunes: RecordView[] + readOnly: RecordView[] + inSyncCount: number +} +export interface ApplyRequest { applyUpdates: boolean; applyPrunes: boolean } +``` + +`web/src/api/client.ts`: +```ts +import { API_BASE } from "@/lib/config" +import type { + Account, CreateAccountInput, Template, CreateTemplateInput, + Domain, CreateDomainInput, ChangesetResponse, ApplyRequest, +} from "./types" + +async function req(path: string, init?: RequestInit): Promise { + const res = await fetch(`${API_BASE}${path}`, { + headers: { "Content-Type": "application/json" }, + method: "GET", + ...init, + }) + if (!res.ok) { + let msg = `HTTP ${res.status}` + try { const b = await res.json(); if (b?.error) msg = b.error } catch { /* ignore */ } + throw new Error(msg) + } + if (res.status === 204) return undefined as T + return (await res.json()) as T +} + +export const api = { + listAccounts: () => req("/accounts"), + createAccount: (input: CreateAccountInput) => + req("/accounts", { method: "POST", body: JSON.stringify(input) }), + deleteAccount: (id: string) => req(`/accounts/${id}`, { method: "DELETE" }), + + listTemplates: () => req("/templates"), + createTemplate: (input: CreateTemplateInput) => + req