diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index e63be25..41cdbd0 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -9,8 +9,53 @@ permissions: contents: read jobs: + ui: + name: ui (build + test + budget) + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-node@v4 + with: + node-version: '20' + cache: 'npm' + cache-dependency-path: ui/package-lock.json + + - name: Install UI dependencies + run: npm --prefix ui ci + + - name: Type check + run: npm --prefix ui run typecheck + + - name: Vitest + run: npm --prefix ui test -- --run --coverage + + - name: Build UI + run: npm --prefix ui run build + + - name: Assert bundle budget (≤ 580 KB JS + CSS, initial chunk) + run: | + set -eu + js_bytes=$(stat -c %s ui/dist/assets/index-*.js 2>/dev/null || stat -f %z ui/dist/assets/index-*.js) + css_bytes=$(stat -c %s ui/dist/assets/index-*.css 2>/dev/null || stat -f %z ui/dist/assets/index-*.css) + total=$((js_bytes + css_bytes)) + echo "bundle: ${js_bytes} js + ${css_bytes} css = ${total} bytes" + if [ "$total" -gt 593920 ]; then + echo "::error::Bundle ${total} B exceeds 580 KB budget" + exit 1 + fi + + - name: Upload ui/dist + uses: actions/upload-artifact@v4 + with: + name: ui-dist + path: ui/dist + retention-days: 1 + if-no-files-found: error + test: name: test (${{ matrix.os }}) + needs: ui strategy: fail-fast: false matrix: @@ -25,8 +70,6 @@ jobs: with: go-version: '1.22' - # mattn/go-sqlite3 needs a C compiler. Ubuntu has gcc by default; - # macOS has Xcode CLT on GitHub runners. - name: Assert C toolchain (macOS) if: runner.os == 'macOS' run: clang --version @@ -42,6 +85,14 @@ jobs: restore-keys: | ${{ runner.os }}-go- + # Hydrate ui/dist with the build artifact produced by the `ui` job so + # the //go:embed ui/dist directive has real assets to embed. + - name: Download ui/dist + uses: actions/download-artifact@v4 + with: + name: ui-dist + path: ui/dist + - name: go vet (cgo + fts5) run: CGO_ENABLED=1 go vet -tags sqlite_fts5 $(go list ./... | grep -v /ui/node_modules/) @@ -61,6 +112,7 @@ jobs: test-integration: name: integration tests (-race) + needs: ui runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 @@ -77,35 +129,13 @@ jobs: ~/go/pkg/mod key: go-integ-${{ hashFiles('go.sum') }} - - name: integration tests - run: CGO_ENABLED=1 go test -tags "sqlite_fts5 integration" -race -timeout 600s ./... - - ui-freshness: - name: ui-dist freshness - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v4 - with: - fetch-depth: 0 - - - uses: actions/setup-node@v4 + - name: Download ui/dist + uses: actions/download-artifact@v4 with: - node-version: '20' - cache: 'npm' - cache-dependency-path: ui/package-lock.json - - - name: Install UI dependencies - run: npm --prefix ui ci - - - name: Build UI - run: npm --prefix ui run build + name: ui-dist + path: ui/dist - - name: vitest - run: npm --prefix ui test -- --run --coverage - - - name: verify ui/dist/ matches fresh build - run: | - if ! git diff --exit-code -- ui/dist/; then - echo "::error::ui/dist/ is stale. Run 'npm --prefix ui run build' and commit the result." - exit 1 - fi + - name: integration tests + # TestHNSW_Recall10k indexes 10k vectors; under -race this pushes well + # past the old 600s. 1200s gives enough slack without being blanket-lax. + run: CGO_ENABLED=1 go test -tags "sqlite_fts5 integration" -race -timeout 1200s ./... diff --git a/.gitignore b/.gitignore index 2d0d97f..3d45ddc 100644 --- a/.gitignore +++ b/.gitignore @@ -24,12 +24,14 @@ go.work.sum *.sqlite-wal # Frontend — build artifacts -# Generic `dist/` ignored, BUT `ui/dist/` is intentionally tracked because -# `//go:embed` needs the built SPA present at `go build` time (we don't run -# npm during `go install`). +# `ui/dist/` is generated by `npm run build` and never committed. We DO +# commit `ui/dist/index.html` — a placeholder the Go `//go:embed ui/dist` +# directive needs at compile time. CI builds the UI before every Go step +# so the embedded assets are always fresh. dist/ !ui/dist/ -!ui/dist/** +ui/dist/* +!ui/dist/index.html # Frontend — tooling caches ui/node_modules/ @@ -60,3 +62,4 @@ Thumbs.db *.log *.tmp *.bak +.superpowers/ diff --git a/cmd/serve.go b/cmd/serve.go index fbb6879..ed23057 100644 --- a/cmd/serve.go +++ b/cmd/serve.go @@ -105,7 +105,11 @@ var serveCmd = &cobra.Command{ if err != nil { return fmt.Errorf("llm provider: %w", err) } - slog.Info("⚙️ LLM provider initialised", "provider", prov.Name(), "model", prov.ModelID()) + if prov == nil { + slog.Info("⚙️ LLM disabled (provider=none) — notes, graph, tree, notes-search available; /api/search and /api/upload will return 503") + } else { + slog.Info("⚙️ LLM provider initialised", "provider", prov.Name(), "model", prov.ModelID()) + } emb := embedder.New(prov, cfg.Indexing.BatchSize) diff --git a/docs/superpowers/plans/2026-04-18-ui-redesign.md b/docs/superpowers/plans/2026-04-18-ui-redesign.md new file mode 100644 index 0000000..81696eb --- /dev/null +++ b/docs/superpowers/plans/2026-04-18-ui-redesign.md @@ -0,0 +1,4060 @@ +# docsiq UI Redesign — 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:** Greenfield rewrite of the docsiq web UI per `docs/superpowers/specs/2026-04-18-ui-redesign-design.md`. Ship a keyboard-first, responsive, WCAG-AA, reduced-motion-aware SPA that replaces the current hand-rolled React+CSS UI without any backend regression. + +**Architecture:** React 19 + Vite 6 + Tailwind 4 + shadcn/ui primitives, TanStack Query for server state, Zustand for client state, React Router 6 for routing, markdown-it + shiki for note rendering, d3-force for graph layout, Framer Motion gated by `prefers-reduced-motion`. Fonts (Geist Sans + Geist Mono) self-hosted. One shared bearer token injected into `` tag by the Go SPA handler. + +**Tech Stack:** React 19, Vite 6, TypeScript 5.7, Tailwind 4, shadcn/ui, Radix, lucide-react, TanStack Query v5, Zustand v5, React Router 6, React Hook Form 7, Zod 3, Framer Motion 11, markdown-it, shiki, d3-force, CodeMirror 6, cmdk, Vitest 3, @testing-library/react 16, MSW 2. + +**Spec:** `docs/superpowers/specs/2026-04-18-ui-redesign-design.md` + +--- + +## File Structure (final state) + +``` +ui/ +├── src/ +│ ├── main.tsx +│ ├── App.tsx +│ ├── routes/ +│ │ ├── Home.tsx +│ │ ├── notes/{NotesLayout,NoteView,NoteEditor,NotesSearch}.tsx +│ │ ├── documents/{DocumentsList,DocumentView,UploadModal}.tsx +│ │ ├── Graph.tsx +│ │ └── MCPConsole.tsx +│ ├── components/ +│ │ ├── ui/ — shadcn primitives (Button, Dialog, Input, etc.) +│ │ ├── layout/{Shell,Sidebar,TopBar,StatsStrip,SkipLink}.tsx +│ │ ├── command/{CommandPalette,ResultRow}.tsx +│ │ ├── activity/{ActivityFeed,EventRow,EventBadge}.tsx +│ │ ├── graph/{GraphCanvas,GlanceView,GraphFilters}.tsx +│ │ ├── notes/{MarkdownView,WikiLink,LinkPanel,TreeDrawer}.tsx +│ │ ├── docs/{DocTable,DocOutline}.tsx +│ │ └── common/{Toast,Skeleton,EmptyState,ErrorBoundary,Kbd}.tsx +│ ├── hooks/ +│ │ ├── api/{useStats,useActivity,useNotes,useDocs,useGraph,useMCP,useCommand}.ts +│ │ ├── useHotkey.ts +│ │ ├── useLastVisit.ts +│ │ └── useReducedMotion.ts +│ ├── stores/{ui,project,toast}.ts +│ ├── lib/{api-client,markdown,graph-layout,utils,format,i18n}.ts +│ ├── i18n/en.ts +│ ├── styles/globals.css +│ └── types/api.ts +├── public/fonts/{Geist-400,Geist-500,Geist-600,GeistMono-400,GeistMono-500}.woff2 +├── tailwind.config.ts +├── vitest.config.ts +├── tsconfig.{json,app.json,node.json} +├── vite.config.ts +├── index.html +├── embed.go — unchanged +└── package.json — name: docsiq-ui + +internal/api/router.go — +8 lines (meta-tag injection, Phase 10) +``` + +--- + +## Wave 0 — Foundation + +### Task 0.1: Create feature branch + +- [ ] **Step 1: Branch from main** + ```bash + cd /home/dev/projects/docsiq + git checkout -b ui-redesign + git push -u origin ui-redesign + ``` + +- [ ] **Step 2: Confirm clean starting state** + ```bash + git status --short; echo "---"; make test 2>&1 | grep -E '^(ok|FAIL)' | head -5 + ``` + Expected: zero lines in `git status`, all packages `ok`. + +### Task 0.2: Wipe old ui/src and ui-root cruft + +**Files:** +- Delete: `ui/src/**`, `ui/app.js`, `ui/graph.js`, `ui/style.css`, `ui/vendor/` + +- [ ] **Step 1: Delete the old UI source** + ```bash + cd /home/dev/projects/docsiq/ui + rm -rf src app.js graph.js style.css vendor + ``` + +- [ ] **Step 2: Verify only config + embed remain** + ```bash + ls -la + ``` + Expected dirs: `public/`, `dist/`, `node_modules/` (if present). Expected files: `embed.go`, `index.html`, `package.json`, `package-lock.json`, `tsconfig*.json`, `vite.config.ts`, `vitest.config.ts`. + +- [ ] **Step 3: Commit the clean slate** + ```bash + cd /home/dev/projects/docsiq + git add -A + git commit -m "chore(ui): wipe pre-redesign src and legacy root cruft" + ``` + +### Task 0.3: Placeholder `ui/dist/` so backend builds stay green + +**Files:** +- Create: `ui/dist/index.html` + +- [ ] **Step 1: Write placeholder** + ```bash + cat > ui/dist/index.html <<'EOF' + + +
+ + +UI redesign in progress. API + MCP paths unchanged.
+wave-0 scaffold complete
++ wave-1 scaffold +
++ Stub — implemented in later wave. +
+No such page.
+body/);
+ expect(parts[0].content).not.toMatch(/title:/);
+ });
+ it("extracts plain wikilink", () => {
+ const parts = renderMarkdown("see [[target]]!");
+ const link = parts.find((p) => p.kind === "wikilink");
+ expect(link?.content).toBe("target");
+ expect(link?.label).toBeUndefined();
+ });
+ it("extracts aliased wikilink and renders alias", () => {
+ const parts = renderMarkdown("see [[target|Alias]]!");
+ const link = parts.find((p) => p.kind === "wikilink");
+ expect(link?.content).toBe("target");
+ expect(link?.label).toBe("Alias");
+ });
+ it("opens external links in new tab", () => {
+ const parts = renderMarkdown("[g](https://example.com)");
+ expect(parts[0].content).toMatch(/target="_blank"/);
+ expect(parts[0].content).toMatch(/rel="noopener noreferrer"/);
+ });
+ it("adds loading=lazy to images", () => {
+ const parts = renderMarkdown("");
+ expect(parts[0].content).toMatch(/loading="lazy"/);
+ });
+ });
+ ```
+
+- [ ] **Step 5: Write `ui/src/components/notes/__tests__/MarkdownView.test.tsx`**
+ ```tsx
+ import { describe, it, expect } from "vitest";
+ import { render, screen } from "@testing-library/react";
+ import { MemoryRouter } from "react-router-dom";
+ import { MarkdownView } from "../MarkdownView";
+
+ describe("MarkdownView", () => {
+ it("renders wikilinks as clickable router links with alias", () => {
+ render( — — {key}Inbound
+ {inbound.length === 0 && Outbound
+ {outbound.length === 0 && Note not found
+ {note.key.split("/").pop()}
+