diff --git a/docs/plans/web/frontend-modernization_output.md b/docs/plans/web/frontend-modernization_output.md new file mode 100644 index 0000000..7a25589 --- /dev/null +++ b/docs/plans/web/frontend-modernization_output.md @@ -0,0 +1,48 @@ +# Frontend Modernization — Output + +Implemented per `docs/plans/web/frontend-modernization_plan.md`, all 5 chunks. + +## Chunk 1 — Design tokens + primitives + +- `src/index.css`: full token rewrite. Dark-first palette (`--canvas`, `--surface`, `--surface-sunken`, `--ink`, `--ink-muted`, `--line`), violet `--accent` default, light-mode override via `prefers-color-scheme` + `data-theme`. +- Five-stage color ramp `--stage-1..5` (violet → blue → coral → teal → gold), shared by `NovelPhase` and `DraftStatus` via `src/api/stage.ts` (`novelPhaseColor`, `draftStatusColor`) — both are 5-step progressions, one hue system backs both. +- Fonts self-hosted via `@fontsource-variable/*` (no CDN dep): Fraunces (display), Inter (UI), Source Serif 4 (prose/markdown), JetBrains Mono (utility). Imported in `src/main.tsx`. +- `src/components/ui.tsx` primitives (`.card`, `.btn`, `.input`, `StatusBadge`, etc.) rebuilt on the new tokens. + +## Chunk 2 — Sidebar shell + +- `src/pages/NovelLayout.tsx` rebuilt: left sidebar (wordmark, novel title, phase pill, icon nav) replaces the old header + horizontal tab bar. Kills the old back-link-next-to-title layout — top bar is now just a breadcrumb. +- New `src/components/icons.tsx` — small hand-written inline SVG icon set (no icon library dependency). +- **Signature element**: novel's `phase` sets `--accent`/`--accent-soft` for the whole layout, scoped via inline style on the layout root. Nav active state, buttons, focus rings, phase pill all recolor together when phase changes. +- Bug fixed during build: breadcrumb section-matching used a suffix `startsWith` check that broke on exact segment matches (`chapters` vs `chapters/`) — replaced with explicit segment split/compare. + +## Chunk 3 — Dashboard rebuild + +- `src/pages/DashboardPage.tsx`: quick-actions row now leads the page — **New chapter** (creates + jumps into the editor), **New character** (reuses the add-character modal, now exported from `CharactersPage.tsx`), and **Continue writing** (jumps to the most-recently-updated chapter, or **View chapters** if nothing's drafted). +- Brainstorming phase gets its own pair above the notes field: **Add a character** / **Move to outlining**. +- Renamed `OutliningDashboard` → `WorkDashboard` (it covers Outlining/Writing/Editing/Complete, not just Outlining — old name was misleading). +- History content (activity graph, recent chapters/characters, tag cloud) unchanged, just repositioned under the new hero row. + +## Chunk 4 — Global agent panel + +- `/agent` route and `pages/AgentPage.tsx` retired. +- New `src/components/AgentPanel.tsx`: fixed slide-out drawer mounted in `NovelLayout` (shell level), reachable from every page in a novel via the sidebar "Agent" toggle or `g a`. Non-modal — background stays interactive. +- **Context-aware**: panel shows "Talking about {X}" — resolves to the specific chapter/character title on detail pages, falls back to section name elsewhere. Each outgoing message gets a `Context: {label}` line prepended (server has no route awareness, so this is how the agent learns what page you're on); stripped back out and shown as a small "re: …" tag on render rather than raw text in the transcript. +- Compacted the old two-pane (sidebar list + chat) layout into a single column with a conversation-switcher dropdown — panel width doesn't fit a full list rail. +- No backend changes — works within the existing `SendAgentMessageRequest` shape. + +## Chunk 5 — Polish + +- Global `:focus-visible` ring via `box-shadow` (not `outline`, to avoid clobbering `TagColorPicker`'s outline-based selection indicator or `.input`'s own focus ring). Ring color follows the phase accent. +- Global `prefers-reduced-motion: reduce` override (`!important` on `animation-duration`/`transition-duration`/`scroll-behavior`) — neutralizes the agent panel's slide transition too, since author `!important` beats a normal-priority inline style in the cascade. +- Fixed two leftover hardcoded `#9a4a2f` (old terracotta accent) defaults in `TagColorPicker.tsx` and `TagsPage.tsx` → new violet `#7c5cff`. +- Audited remaining pages (Settings, Locations, Tags, Characters, Chapters) — all inherit cleanly from the chunk-1 primitives already, no stale styling found. + +## Verification + +Every chunk built clean (`npm run build`) and was clicked through live in Chrome against a local API + SQLite instance — login/signup, novel creation, phase switching (confirmed accent recolor live: violet → blue → coral), chapter/character creation flows, agent panel open/close/context-swap across navigation, keyboard focus ring. + +## Deferred / not done + +- Sidebar collapse toggle — mentioned in the original plan's layout description ("persistent, icon+label, collapsible") but never implemented; flagged as deferred in chunk 2 and again in chunk 5. Would need its own pass (collapsed-width icon rail, persisted preference). +- No backend/API changes anywhere in this arc — all five chunks were frontend-only. diff --git a/docs/plans/web/frontend-modernization_plan.md b/docs/plans/web/frontend-modernization_plan.md new file mode 100644 index 0000000..e1cf204 --- /dev/null +++ b/docs/plans/web/frontend-modernization_plan.md @@ -0,0 +1,41 @@ +# Frontend Modernization Plan + +## Design direction + +Drop warm-paper/serif "manuscript" look — reads dated, low-contrast, single dull accent. New identity: **phase-driven color**. Novelly already models a novel's lifecycle as phases (`Brainstorming → Outlining → Drafting → Revising → Final`, see `novelPhases`, `StatusBadge` tones). Make that real data drive the whole app's mood instead of hiding in a badge — the active novel's phase sets an accent hue across nav, buttons, focus rings, charts. Writer sees at a glance "I'm in draft mode" vs "polishing." Distinctive, grounded in the product's own model, not decoration. + +### Tokens + +Color (base neutrals, dark-first): +- `--ink: #14121a` / `--ink-muted: #8b859a` +- `--surface: #1b1825` (panel/card) / `--surface-sunken: #100e17` +- `--canvas: #0c0a12` (app background) +- `--line: #2c2838` +- Light mode mirrors with `--canvas:#f7f6fb`, `--surface:#ffffff`, `--ink:#14121a` + +Phase accents (used for `--accent` + `--accent-soft`, swapped by `novel.phase`): +- Brainstorming — `#7c5cff` violet +- Outlining — `#2f8fe0` blue +- Drafting — `#ff7a45` coral +- Revising — `#14b88a` teal +- Final — `#d9a404` gold + +Type: +- Display (headlines, dashboard hero, page titles): **Fraunces** — variable serif w/ real character, used large/sparingly +- UI (nav, buttons, body chrome): **Inter** +- Prose editing (chapter/beat text, agent transcript): keep a serif for long-form reading — **Source Serif 4** replaces Iowan/Palatino (renders consistently, not Mac-only) +- Utility/data (counts, timestamps, mono bits): **JetBrains Mono** + +Layout: left sidebar nav (persistent, icon+label, collapsible), agent as a right-docked slide-out panel triggered from anywhere (sidebar icon, always visible), main content full-bleed under a slim top bar (breadcrumb + phase pill + user menu — no more "← Novels" link floating left of the title). + +Signature element: the phase-accent system itself — nav active states, primary buttons, focus rings, and the dashboard's activity graph all recolor together when phase changes. Nothing else in the app competes for boldness; everything else stays a disciplined dark neutral. + +## Chunks (each independently buildable/committable) + +1. **Design tokens + primitives** — rewrite `index.css` theme (colors, fonts incl. `@font-face`/Google Fonts imports, spacing), update `ui.tsx` primitives (`btn`, `card`, `input`, `StatusBadge`) to new tokens. No layout changes yet — existing pages just reskin. Fastest way to see the new palette/type everywhere at once. +2. **App shell: sidebar nav** — replace `NovelLayout`'s header+tab-bar with left sidebar (novel switcher, section nav, phase pill), slim top bar. Fixes the back-link-left-of-title complaint structurally. Agent gets a nav icon but no panel yet (still routes to `/agent` page). +3. **Dashboard rebuild** — make it the true home: recent activity + work history (already there) alongside prominent "start new work" actions (new chapter, new character, continue last chapter) above the fold. This is the biggest content/layout change, isolated to one page. +4. **Global agent panel** — extract `AgentPage`'s chat UI into a slide-out panel mounted at the app shell level (outside ``), triggered from the sidebar on any route, passes current route/entity as context. Retire the standalone `/agent` route once panel covers it. +5. **Polish pass** — motion (panel slide, nav active-state transitions, dashboard load-in), empty states, focus-visible/reduced-motion audit, remaining pages (Characters/Chapters/Tags/Locations/Settings) get spacing/type touch-ups to match new primitives from chunk 1. + +Suggest reviewing after each chunk before starting the next — chunk 2 and 4 both touch navigation/shell so seeing 1–2 landed first will make it obvious if the sidebar direction is right before the agent panel builds on top of it. diff --git a/src/Novelly.Web/package-lock.json b/src/Novelly.Web/package-lock.json index 268d00e..fa5536a 100644 --- a/src/Novelly.Web/package-lock.json +++ b/src/Novelly.Web/package-lock.json @@ -8,6 +8,10 @@ "name": "novelly-web", "version": "0.0.0", "dependencies": { + "@fontsource-variable/fraunces": "^5.3.0", + "@fontsource-variable/inter": "^5.3.0", + "@fontsource-variable/source-serif-4": "^5.3.0", + "@fontsource/jetbrains-mono": "^5.3.0", "@tanstack/react-query": "^5.101.4", "react": "^19.2.8", "react-dom": "^19.2.8", @@ -27,6 +31,38 @@ "vite": "^8.2.0" } }, + "node_modules/@fontsource-variable/fraunces": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/@fontsource-variable/fraunces/-/fraunces-5.3.0.tgz", + "integrity": "sha512-9BYGySn4AHEJdgp9Z28tQ3X+laJMEOITXkQarZXeloWQZDq5oOvXJ3kDA8c7MGIfpogIaZfjrQBqmda8POOCKA==", + "funding": { + "url": "https://github.com/sponsors/ayuhito" + } + }, + "node_modules/@fontsource-variable/inter": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/@fontsource-variable/inter/-/inter-5.3.0.tgz", + "integrity": "sha512-OupL48va4JNofb97w6NYeF9S7W/kHNKM0Er8Dem5nqi4jeOLrVJDoE8tZEpnMJmtkvNbB1EIPPwHcdkF6b1oUA==", + "funding": { + "url": "https://github.com/sponsors/ayuhito" + } + }, + "node_modules/@fontsource-variable/source-serif-4": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/@fontsource-variable/source-serif-4/-/source-serif-4-5.3.0.tgz", + "integrity": "sha512-9vch9WqxjaaA+1o9Ur8pOgIGbCYLjRReOUel23A6lOpD1syptgjtkORevvNmldJ5kGXQL29onQUqI5Ltz0s3bQ==", + "funding": { + "url": "https://github.com/sponsors/ayuhito" + } + }, + "node_modules/@fontsource/jetbrains-mono": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/@fontsource/jetbrains-mono/-/jetbrains-mono-5.3.0.tgz", + "integrity": "sha512-fqDfB5I9f1p1TV486aUgB9t8zP84P0O1FtQR5Ol9vjwPy+S+EIGlVYm1cvj2W5shcZMTg2nZFdVMoH5wFu8a1A==", + "funding": { + "url": "https://github.com/sponsors/ayuhito" + } + }, "node_modules/@jridgewell/gen-mapping": { "version": "0.3.13", "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", diff --git a/src/Novelly.Web/package.json b/src/Novelly.Web/package.json index a8326e7..4fd2b10 100644 --- a/src/Novelly.Web/package.json +++ b/src/Novelly.Web/package.json @@ -10,6 +10,10 @@ "preview": "vite preview" }, "dependencies": { + "@fontsource-variable/fraunces": "^5.3.0", + "@fontsource-variable/inter": "^5.3.0", + "@fontsource-variable/source-serif-4": "^5.3.0", + "@fontsource/jetbrains-mono": "^5.3.0", "@tanstack/react-query": "^5.101.4", "react": "^19.2.8", "react-dom": "^19.2.8", diff --git a/src/Novelly.Web/src/App.tsx b/src/Novelly.Web/src/App.tsx index 88a8c0f..efd5360 100644 --- a/src/Novelly.Web/src/App.tsx +++ b/src/Novelly.Web/src/App.tsx @@ -8,7 +8,6 @@ import TagsPage from './pages/TagsPage' import LocationsPage from './pages/LocationsPage' import ChaptersPage from './pages/ChaptersPage' import ChapterPage from './pages/ChapterPage' -import AgentPage from './pages/AgentPage' import SettingsPage from './pages/SettingsPage' import LoginPage from './pages/LoginPage' import { AuthProvider, useAuth } from './auth/AuthContext' @@ -44,7 +43,6 @@ export default function App() { } /> } /> } /> - } /> } /> } /> diff --git a/src/Novelly.Web/src/api/stage.ts b/src/Novelly.Web/src/api/stage.ts new file mode 100644 index 0000000..002c8d8 --- /dev/null +++ b/src/Novelly.Web/src/api/stage.ts @@ -0,0 +1,27 @@ +import type { DraftStatus, NovelPhase } from './types' + +const stageVar = (index: 1 | 2 | 3 | 4 | 5) => `var(--stage-${index})` + +const novelPhaseStage: Record = { + Brainstorming: 1, + Outlining: 2, + Writing: 3, + Editing: 4, + Complete: 5, +} + +const draftStatusStage: Record = { + Planned: 1, + Outlined: 2, + Drafted: 3, + Revised: 4, + Final: 5, +} + +export function novelPhaseColor(phase: NovelPhase): string { + return stageVar(novelPhaseStage[phase]) +} + +export function draftStatusColor(status: DraftStatus): string { + return stageVar(draftStatusStage[status]) +} diff --git a/src/Novelly.Web/src/components/AgentPanel.tsx b/src/Novelly.Web/src/components/AgentPanel.tsx new file mode 100644 index 0000000..96eb869 --- /dev/null +++ b/src/Novelly.Web/src/components/AgentPanel.tsx @@ -0,0 +1,196 @@ +import { useEffect, useRef, useState } from 'react' +import { useConversation, useConversations, useSendAgentMessage } from '../api/hooks' +import type { AgentMessage } from '../api/types' +import { ErrorNote, Spinner } from './ui' +import { IconPlus } from './icons' +import { useHotkey } from '../keyboard/HotkeysContext' + +const starters = [ + 'Read the brief and tell me what the outline is missing.', + "Draft a three-act skeleton from the logline, then stop so I can react.", + 'Look at my protagonist: is the want genuinely in tension with the need?', +] + +const CONTEXT_PATTERN = /^Context: (.+)\n\n([\s\S]*)$/ + +export interface AgentContext { + label: string +} + +export function AgentPanel({ + novelId, + open, + onClose, + context, +}: { + novelId: string + open: boolean + onClose: () => void + context: AgentContext | null +}) { + const { data: conversations } = useConversations(novelId) + const [conversationId, setConversationId] = useState() + const { data: conversation } = useConversation(conversationId) + const send = useSendAgentMessage(novelId) + const [draft, setDraft] = useState('') + const endRef = useRef(null) + + useEffect(() => { + if (open) endRef.current?.scrollIntoView({ behavior: 'smooth' }) + }, [conversation?.messages.length, send.isPending, open]) + + const submit = (message: string) => { + const trimmed = message.trim() + if (!trimmed || send.isPending) return + setDraft('') + const withContext = context ? `Context: ${context.label}\n\n${trimmed}` : trimmed + send.mutate( + { message: withContext, conversationId }, + { onSuccess: (turn) => setConversationId(turn.conversationId) }, + ) + } + + useHotkey('mod+Enter', 'Send message', () => submit(draft), { + group: 'Agent', + allowInInputs: true, + enabled: open && draft.trim().length > 0 && !send.isPending, + }) + useHotkey('Escape', 'Close agent', onClose, { group: 'Agent', enabled: open, allowInInputs: true }) + + return ( +
+
+

+ Agent +

+ {conversations && conversations.length > 0 && ( + + )} + + +
+ + {context && ( +
+ Talking about {context.label} +
+ )} + +
+ {!conversation && ( +
+

Your writing partner

+

+ It can read and edit the brief, the outline, character dossiers and chapter prose — + the same data you see elsewhere in the app. +

+
+ {starters.map((starter) => ( + + ))} +
+
+ )} + + {conversation?.messages.map((message) => ( + + ))} + + {send.isPending && } + {send.error && } +
+
+ +
{ + e.preventDefault() + submit(draft) + }} + > +