Modernize web client: dark phase-driven theme, sidebar shell, global agent panel

Replaces the warm-paper/serif look with a dark palette where the novel's
lifecycle phase drives the accent color app-wide. Sidebar nav replaces the
old header/tab-bar. Dashboard leads with quick actions (new chapter, new
character, continue writing) instead of just showing history. Agent chat
is now a context-aware slide-out panel reachable from any page in a novel,
replacing the buried /agent tab.
This commit is contained in:
James Wampler
2026-08-20 14:21:44 -07:00
parent 661f2917ea
commit 1423977ed4
18 changed files with 859 additions and 295 deletions
@@ -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.
@@ -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 `<Outlet>`), 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 12 landed first will make it obvious if the sidebar direction is right before the agent panel builds on top of it.
+36
View File
@@ -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",
+4
View File
@@ -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",
-2
View File
@@ -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() {
<Route path="chapters/:chapterId" element={<ChapterPage />} />
<Route path="tags" element={<TagsPage />} />
<Route path="locations" element={<LocationsPage />} />
<Route path="agent" element={<AgentPage />} />
<Route path="settings" element={<SettingsPage />} />
</Route>
<Route path="*" element={<NovelsPage />} />
+27
View File
@@ -0,0 +1,27 @@
import type { DraftStatus, NovelPhase } from './types'
const stageVar = (index: 1 | 2 | 3 | 4 | 5) => `var(--stage-${index})`
const novelPhaseStage: Record<NovelPhase, 1 | 2 | 3 | 4 | 5> = {
Brainstorming: 1,
Outlining: 2,
Writing: 3,
Editing: 4,
Complete: 5,
}
const draftStatusStage: Record<DraftStatus, 1 | 2 | 3 | 4 | 5> = {
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])
}
@@ -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<string | undefined>()
const { data: conversation } = useConversation(conversationId)
const send = useSendAgentMessage(novelId)
const [draft, setDraft] = useState('')
const endRef = useRef<HTMLDivElement>(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 (
<div
id="agent-panel"
className="fixed top-0 right-0 z-30 flex h-screen w-full flex-col sm:w-[26rem]"
style={{
background: 'var(--surface)',
borderLeft: '1px solid var(--line)',
boxShadow: open ? '-12px 0 32px -12px rgba(0,0,0,0.45)' : 'none',
transform: open ? 'translateX(0)' : 'translateX(100%)',
transition: 'transform 220ms ease',
}}
aria-hidden={!open}
>
<header className="flex items-center gap-2 px-4 py-3" style={{ borderBottom: '1px solid var(--line)' }}>
<h2 className="text-sm font-semibold" style={{ fontFamily: 'var(--font-display)' }}>
Agent
</h2>
{conversations && conversations.length > 0 && (
<select
className="input ml-2 w-auto flex-1 py-1 text-xs"
value={conversationId ?? ''}
onChange={(e) => setConversationId(e.target.value || undefined)}
aria-label="Conversation"
>
<option value="">New conversation</option>
{conversations.map((item) => (
<option key={item.id} value={item.id}>
{item.title}
</option>
))}
</select>
)}
<button
className="btn ml-auto px-2 py-1"
title="New conversation"
onClick={() => setConversationId(undefined)}
>
<IconPlus width={14} height={14} />
</button>
<button className="btn px-2 py-1" onClick={onClose} aria-label="Close agent panel">
</button>
</header>
{context && (
<div className="px-4 pt-3 text-xs muted">
Talking about <span style={{ color: 'var(--accent)' }}>{context.label}</span>
</div>
)}
<div className="flex-1 space-y-4 overflow-y-auto px-4 py-4">
{!conversation && (
<div className="card p-4">
<h3 className="font-semibold">Your writing partner</h3>
<p className="mt-1 text-sm muted">
It can read and edit the brief, the outline, character dossiers and chapter prose
the same data you see elsewhere in the app.
</p>
<div className="mt-3 grid gap-2">
{starters.map((starter) => (
<button key={starter} className="btn justify-start text-left text-sm" onClick={() => submit(starter)}>
{starter}
</button>
))}
</div>
</div>
)}
{conversation?.messages.map((message) => (
<MessageBubble key={message.id} message={message} />
))}
{send.isPending && <Spinner label="Thinking" />}
{send.error && <ErrorNote error={send.error} />}
<div ref={endRef} />
</div>
<form
className="flex gap-2 px-4 py-3"
style={{ borderTop: '1px solid var(--line)' }}
onSubmit={(e) => {
e.preventDefault()
submit(draft)
}}
>
<textarea
className="input flex-1 resize-none"
rows={2}
value={draft}
placeholder="Ask about structure, a character's arc, or what the next beat should do…"
onChange={(e) => setDraft(e.target.value)}
/>
<button className="btn btn-primary self-end" disabled={!draft.trim() || send.isPending}>
Send
</button>
</form>
</div>
)
}
function MessageBubble({ message }: { message: AgentMessage }) {
const isUser = message.role === 'User'
const match = isUser ? message.content.match(CONTEXT_PATTERN) : null
const contextLabel = match?.[1]
const content = match ? match[2] : message.content
return (
<div className={isUser ? 'flex justify-end' : ''}>
<div
className={`card max-w-[90%] px-3.5 py-2.5 ${isUser ? '' : 'w-full'}`}
style={isUser ? { background: 'var(--accent-soft)', borderColor: 'transparent' } : undefined}
>
{contextLabel && <div className="mb-1 text-[0.6875rem] muted">re: {contextLabel}</div>}
<div className="prose-serif whitespace-pre-wrap text-[0.9375rem]">{content}</div>
{message.toolCalls.length > 0 && (
<details className="mt-3">
<summary className="cursor-pointer text-xs muted">
{message.toolCalls.length} change
{message.toolCalls.length === 1 ? '' : 's'} made
</summary>
<ul className="mt-2 grid gap-2">
{message.toolCalls.map((call, index) => (
<li key={index} className="rounded-md p-2 text-xs" style={{ background: 'var(--surface-sunken)' }}>
<div className="font-mono font-semibold">{call.name}</div>
<pre className="mt-1 overflow-x-auto whitespace-pre-wrap break-words opacity-70">
{call.input}
</pre>
</li>
))}
</ul>
</details>
)}
</div>
</div>
)
}
@@ -76,7 +76,7 @@ export function TagColorPicker({
id="tag-color-custom-input"
className="sr-only"
type="color"
value={isCustom ? value : '#9a4a2f'}
value={isCustom ? value : '#7c5cff'}
disabled={readOnly}
onChange={(e) => onChange(e.target.value)}
/>
+109
View File
@@ -0,0 +1,109 @@
import type { SVGProps } from 'react'
function Icon(props: SVGProps<SVGSVGElement>) {
return (
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth={1.75}
strokeLinecap="round"
strokeLinejoin="round"
width={18}
height={18}
aria-hidden
{...props}
/>
)
}
export function IconDashboard(props: SVGProps<SVGSVGElement>) {
return (
<Icon {...props}>
<rect x="3.5" y="3.5" width="7" height="9" rx="1.5" />
<rect x="13.5" y="3.5" width="7" height="5" rx="1.5" />
<rect x="13.5" y="11.5" width="7" height="9" rx="1.5" />
<rect x="3.5" y="15.5" width="7" height="5" rx="1.5" />
</Icon>
)
}
export function IconChapters(props: SVGProps<SVGSVGElement>) {
return (
<Icon {...props}>
<path d="M4 4.5c2-1 5-1 8 0 3-1 6-1 8 0v14c-2-1-5-1-8 0-3-1-6-1-8 0z" />
<path d="M12 4.5v14" />
</Icon>
)
}
export function IconCharacters(props: SVGProps<SVGSVGElement>) {
return (
<Icon {...props}>
<circle cx="9" cy="8" r="3.25" />
<path d="M3.5 20c.7-3.4 2.8-5.5 5.5-5.5s4.8 2.1 5.5 5.5" />
<path d="M15.5 5.2c1.4.4 2.4 1.6 2.4 3s-1 2.6-2.4 3" />
<path d="M18 14.6c2 .5 3.4 2.2 4 4.9" />
</Icon>
)
}
export function IconTags(props: SVGProps<SVGSVGElement>) {
return (
<Icon {...props}>
<path d="M11.5 3.5h5.8a1 1 0 0 1 .7.3l3 3a1 1 0 0 1 .3.7v5.8a1 1 0 0 1-.3.7l-8.3 8.3a1 1 0 0 1-1.4 0l-8-8a1 1 0 0 1 0-1.4l8.3-8.3a1 1 0 0 1 .7-.3z" />
<circle cx="16.5" cy="7.5" r="1.25" />
</Icon>
)
}
export function IconLocations(props: SVGProps<SVGSVGElement>) {
return (
<Icon {...props}>
<path d="M12 21s-7-6.2-7-11.5a7 7 0 0 1 14 0C19 14.8 12 21 12 21z" />
<circle cx="12" cy="9.5" r="2.5" />
</Icon>
)
}
export function IconAgent(props: SVGProps<SVGSVGElement>) {
return (
<Icon {...props}>
<path d="M12 3.5l1.4 3.4 3.4 1.4-3.4 1.4L12 13.1l-1.4-3.4-3.4-1.4 3.4-1.4z" />
<path d="M18.5 14.5l.8 1.9 1.9.8-1.9.8-.8 1.9-.8-1.9-1.9-.8 1.9-.8z" />
</Icon>
)
}
export function IconSettings(props: SVGProps<SVGSVGElement>) {
return (
<Icon {...props}>
<circle cx="12" cy="12" r="3" />
<path d="M12 3.5v2.2M12 18.3v2.2M20.5 12h-2.2M5.7 12H3.5M17.7 6.3l-1.6 1.6M7.9 16.1l-1.6 1.6M17.7 17.7l-1.6-1.6M7.9 7.9 6.3 6.3" />
</Icon>
)
}
export function IconChevronDown(props: SVGProps<SVGSVGElement>) {
return (
<Icon {...props}>
<path d="M6 9l6 6 6-6" />
</Icon>
)
}
export function IconPlus(props: SVGProps<SVGSVGElement>) {
return (
<Icon {...props}>
<path d="M12 5v14M5 12h14" />
</Icon>
)
}
export function IconArrowRight(props: SVGProps<SVGSVGElement>) {
return (
<Icon {...props}>
<path d="M4.5 12h15M13.5 6l6 6-6 6" />
</Icon>
)
}
+2 -9
View File
@@ -1,4 +1,5 @@
import { useEffect, useId, useRef, useState, type MouseEvent, type ReactNode } from 'react'
import { draftStatusColor } from '../api/stage'
import type { DraftStatus } from '../api/types'
export function Spinner({ label = 'Loading' }: { label?: string }) {
@@ -35,16 +36,8 @@ export function EmptyState({ title, hint }: { title: string; hint?: ReactNode })
)
}
const statusTone: Record<DraftStatus, string> = {
Planned: '#8a8178',
Outlined: '#5b7fa8',
Drafted: '#a8813f',
Revised: '#63914f',
Final: '#4a8f7b',
}
export function StatusBadge({ status }: { status: DraftStatus }) {
const tone = statusTone[status]
const tone = draftStatusColor(status)
return (
<span
className="inline-block rounded-full px-2 py-0.5 text-[0.6875rem] font-semibold tracking-wide uppercase"
+74 -51
View File
@@ -1,58 +1,56 @@
@import 'tailwindcss';
@theme {
--font-sans: 'Iowan Old Style', 'Palatino Linotype', Palatino, Georgia, serif;
--font-ui: ui-sans-serif, system-ui, -apple-system, 'Segoe UI', sans-serif;
--font-mono: ui-monospace, 'SF Mono', Menlo, Consolas, monospace;
--font-display: 'Fraunces Variable', Georgia, serif;
--font-ui: 'Inter Variable', ui-sans-serif, system-ui, -apple-system, 'Segoe UI', sans-serif;
--font-serif: 'Source Serif 4 Variable', Georgia, serif;
--font-mono: 'JetBrains Mono', ui-monospace, 'SF Mono', Menlo, Consolas, monospace;
}
:root {
--paper: #faf7f0;
--surface: #ffffff;
--surface-sunken: #f2ede2;
--ink: #241f1a;
--ink-muted: #6b6157;
--line: #e0d8c8;
--accent: #9a4a2f;
--accent-soft: #f6e9e2;
color-scheme: light;
}
--canvas: #0c0a12;
--surface: #1b1825;
--surface-sunken: #100e17;
--ink: #f1eef8;
--ink-muted: #8b859a;
--line: #2c2838;
@media (prefers-color-scheme: dark) {
:root {
--paper: #16151a;
--surface: #1e1d24;
--surface-sunken: #131217;
--ink: #ece7de;
--ink-muted: #9a9288;
--line: #322f39;
--accent: #e08b62;
--accent-soft: #2c2229;
color-scheme: dark;
}
}
--accent: #7c5cff;
--accent-soft: #241d3d;
--accent-ink: #ffffff;
--stage-1: #7c5cff;
--stage-2: #2f8fe0;
--stage-3: #ff7a45;
--stage-4: #14b88a;
--stage-5: #d9a504;
:root[data-theme='dark'] {
--paper: #16151a;
--surface: #1e1d24;
--surface-sunken: #131217;
--ink: #ece7de;
--ink-muted: #9a9288;
--line: #322f39;
--accent: #e08b62;
--accent-soft: #2c2229;
color-scheme: dark;
}
@media (prefers-color-scheme: light) {
:root:not([data-theme='dark']) {
--canvas: #f7f6fb;
--surface: #ffffff;
--surface-sunken: #eeecf6;
--ink: #14121a;
--ink-muted: #6b6577;
--line: #e2dfec;
--accent-soft: #efe9ff;
--accent-ink: #ffffff;
color-scheme: light;
}
}
:root[data-theme='light'] {
--paper: #faf7f0;
--canvas: #f7f6fb;
--surface: #ffffff;
--surface-sunken: #f2ede2;
--ink: #241f1a;
--ink-muted: #6b6157;
--line: #e0d8c8;
--accent: #9a4a2f;
--accent-soft: #f6e9e2;
--surface-sunken: #eeecf6;
--ink: #14121a;
--ink-muted: #6b6577;
--line: #e2dfec;
--accent-soft: #efe9ff;
--accent-ink: #ffffff;
color-scheme: light;
}
@@ -63,21 +61,44 @@ body,
}
body {
background: var(--paper);
background: var(--canvas);
color: var(--ink);
font-family: var(--font-ui);
-webkit-font-smoothing: antialiased;
}
h1,
h2,
h3 {
font-family: var(--font-display);
}
:focus-visible {
outline: none;
box-shadow: 0 0 0 2px var(--canvas), 0 0 0 4px var(--accent, #7c5cff);
border-radius: 0.25rem;
}
@media (prefers-reduced-motion: reduce) {
*,
*::before,
*::after {
animation-duration: 0.01ms !important;
animation-iteration-count: 1 !important;
transition-duration: 0.01ms !important;
scroll-behavior: auto !important;
}
}
@layer components {
.card {
background: var(--surface);
border: 1px solid var(--line);
border-radius: 0.5rem;
border-radius: 0.75rem;
}
.btn {
@apply inline-flex items-center gap-1.5 rounded-md px-3 py-1.5 text-sm font-medium transition;
@apply inline-flex items-center gap-1.5 rounded-lg px-3 py-1.5 text-sm font-medium transition;
border: 1px solid var(--line);
background: var(--surface);
color: var(--ink);
@@ -94,11 +115,11 @@ body {
.btn-primary {
background: var(--accent);
border-color: var(--accent);
color: #fff;
color: var(--accent-ink);
}
.btn-primary:hover:not(:disabled) {
filter: brightness(1.08);
filter: brightness(1.1);
background: var(--accent);
}
@@ -112,7 +133,7 @@ body {
}
.input {
@apply w-full rounded-md px-2.5 py-1.5 text-sm outline-none transition;
@apply w-full rounded-lg px-2.5 py-1.5 text-sm outline-none transition;
background: var(--surface);
border: 1px solid var(--line);
color: var(--ink);
@@ -121,7 +142,7 @@ body {
.input:focus {
border-color: var(--accent);
box-shadow: 0 0 0 3px color-mix(in srgb, var(--accent) 18%, transparent);
box-shadow: 0 0 0 3px color-mix(in srgb, var(--accent) 24%, transparent);
}
.label {
@@ -134,17 +155,18 @@ body {
}
.prose-serif {
font-family: var(--font-sans);
font-family: var(--font-serif);
@apply text-[1.0625rem] leading-relaxed;
}
.markdown-preview {
font-family: var(--font-sans);
font-family: var(--font-serif);
@apply text-[1.0625rem] leading-relaxed;
}
.markdown-preview :is(h1, h2, h3, h4) {
@apply mt-5 mb-2 font-semibold first:mt-0;
font-family: var(--font-display);
}
.markdown-preview h1 {
@@ -183,6 +205,7 @@ body {
.markdown-preview code {
@apply rounded px-1 py-0.5 text-sm;
font-family: var(--font-mono);
background: var(--surface-sunken);
}
+6
View File
@@ -3,6 +3,12 @@ import { createRoot } from 'react-dom/client'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { BrowserRouter } from 'react-router-dom'
import App from './App'
import '@fontsource-variable/fraunces/wght.css'
import '@fontsource-variable/fraunces/wght-italic.css'
import '@fontsource-variable/inter/wght.css'
import '@fontsource-variable/source-serif-4/wght.css'
import '@fontsource/jetbrains-mono/400.css'
import '@fontsource/jetbrains-mono/600.css'
import './index.css'
const queryClient = new QueryClient({
-161
View File
@@ -1,161 +0,0 @@
import { useEffect, useRef, useState } from 'react'
import { useParams } from 'react-router-dom'
import { useConversation, useConversations, useSendAgentMessage } from '../api/hooks'
import type { AgentMessage } from '../api/types'
import { ErrorNote, Spinner } from '../components/ui'
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?',
]
export default function AgentPage() {
const { novelId = '' } = useParams()
const { data: conversations } = useConversations(novelId)
const [conversationId, setConversationId] = useState<string | undefined>()
const { data: conversation } = useConversation(conversationId)
const send = useSendAgentMessage(novelId)
const [draft, setDraft] = useState('')
const endRef = useRef<HTMLDivElement>(null)
useEffect(() => {
endRef.current?.scrollIntoView({ behavior: 'smooth' })
}, [conversation?.messages.length, send.isPending])
const submit = (message: string) => {
const trimmed = message.trim()
if (!trimmed || send.isPending) return
setDraft('')
send.mutate(
{ message: trimmed, conversationId },
{ onSuccess: (turn) => setConversationId(turn.conversationId) },
)
}
useHotkey('n', 'New conversation', () => setConversationId(undefined), { group: 'Agent' })
useHotkey('mod+Enter', 'Send message', () => submit(draft), {
group: 'Agent',
allowInInputs: true,
enabled: draft.trim().length > 0 && !send.isPending,
})
return (
<div className="grid gap-6 lg:grid-cols-[15rem_1fr]">
<aside className="grid content-start gap-2">
<button
className="btn btn-primary w-full justify-center"
onClick={() => setConversationId(undefined)}
>
New conversation
</button>
{conversations?.map((item) => (
<button
key={item.id}
onClick={() => setConversationId(item.id)}
className="card px-3 py-2 text-left text-sm transition hover:shadow-sm"
style={
item.id === conversationId
? { borderColor: 'var(--accent)', background: 'var(--accent-soft)' }
: undefined
}
>
<div className="truncate">{item.title}</div>
<div className="text-xs muted">
{item.messageCount} message{item.messageCount === 1 ? '' : 's'}
</div>
</button>
))}
</aside>
<section className="flex min-h-[70vh] flex-col">
<div className="flex-1 space-y-4 overflow-y-auto pb-4">
{!conversation && (
<div className="card p-6">
<h2 className="text-lg font-semibold">Your writing partner</h2>
<p className="mt-1 text-sm muted">
It can read and edit the brief, the outline, character dossiers and chapter
prose the same data you see in the other tabs.
</p>
<div className="mt-4 grid gap-2">
{starters.map((starter) => (
<button
key={starter}
className="btn justify-start text-left"
onClick={() => submit(starter)}
>
{starter}
</button>
))}
</div>
</div>
)}
{conversation?.messages.map((message) => (
<MessageBubble key={message.id} message={message} />
))}
{send.isPending && <Spinner label="Thinking" />}
{send.error && <ErrorNote error={send.error} />}
<div ref={endRef} />
</div>
<form
className="flex gap-2 pt-3"
style={{ borderTop: '1px solid var(--line)' }}
onSubmit={(e) => {
e.preventDefault()
submit(draft)
}}
>
<textarea
className="input flex-1 resize-none"
rows={3}
value={draft}
placeholder="Ask about structure, a character's arc, or what the next beat should do…"
onChange={(e) => setDraft(e.target.value)}
/>
<button className="btn btn-primary self-end" disabled={!draft.trim() || send.isPending}>
Send
</button>
</form>
<p className="mt-1 text-xs muted">/Ctrl + Enter to send.</p>
</section>
</div>
)
}
function MessageBubble({ message }: { message: AgentMessage }) {
const isUser = message.role === 'User'
return (
<div className={isUser ? 'flex justify-end' : ''}>
<div
className={`card max-w-[46rem] px-4 py-3 ${isUser ? '' : 'w-full'}`}
style={isUser ? { background: 'var(--accent-soft)', borderColor: 'transparent' } : undefined}
>
<div className="prose-serif whitespace-pre-wrap">{message.content}</div>
{message.toolCalls.length > 0 && (
<details className="mt-3">
<summary className="cursor-pointer text-xs muted">
{message.toolCalls.length} change
{message.toolCalls.length === 1 ? '' : 's'} made
</summary>
<ul className="mt-2 grid gap-2">
{message.toolCalls.map((call, index) => (
<li key={index} className="rounded-md p-2 text-xs" style={{ background: 'var(--surface-sunken)' }}>
<div className="font-mono font-semibold">{call.name}</div>
<pre className="mt-1 overflow-x-auto whitespace-pre-wrap break-words opacity-70">
{call.input}
</pre>
</li>
))}
</ul>
</details>
)}
</div>
</div>
)
}
@@ -257,7 +257,6 @@ export default function ChapterPage() {
value={chapter.summary}
multiline
rows={5}
serif
placeholder="What this chapter is for: where it starts, what shifts, where it leaves the reader."
onCommit={(summary) => patch({ summary })}
onContextMenu={(e) => handleContextMenu(e, () => {})}
@@ -622,7 +621,6 @@ function BeatTable({
value={beat.whatHappened}
multiline
rows={3}
serif
placeholder="The event itself."
onCommit={(whatHappened) => patch(beat.id, { whatHappened })}
onContextMenu={(e) =>
@@ -638,7 +636,6 @@ function BeatTable({
value={beat.whatsNext}
multiline
rows={3}
serif
placeholder="What it sets in motion."
onCommit={(whatsNext) => patch(beat.id, { whatsNext })}
onContextMenu={(e) =>
+1 -1
View File
@@ -324,7 +324,7 @@ function CharacterTable({ characters, novelId }: { characters: Character[]; nove
)
}
function AddCharacterModal({
export function AddCharacterModal({
novelId,
onClose,
onCreated,
+157 -20
View File
@@ -1,9 +1,12 @@
import { Link, useParams } from 'react-router-dom'
import { useChapters, useCharacters, useNovel, useNovelActivity, useTags, useUpdateNovel } from '../api/hooks'
import type { Novel, TagSummary } from '../api/types'
import { useState } from 'react'
import { Link, useNavigate, useParams } from 'react-router-dom'
import { useChapters, useCharacters, useCreateChapter, useNovel, useNovelActivity, useTags, useUpdateNovel } from '../api/hooks'
import type { ChapterSummary, Novel, TagSummary } from '../api/types'
import { useAuth } from '../auth/AuthContext'
import { AutoField, EmptyState, ErrorNote, Spinner, StatusBadge } from '../components/ui'
import { ContributionGraph } from '../components/ContributionGraph'
import { IconArrowRight, IconCharacters, IconChapters as IconChapter, IconPlus } from '../components/icons'
import { AddCharacterModal } from './CharactersPage'
const RECENT_COUNT = 5
const RECENT_CHAPTERS_COUNT = 10
@@ -18,39 +21,73 @@ export default function DashboardPage() {
return novel.phase === 'Brainstorming' ? (
<BrainstormingDashboard novel={novel} />
) : (
<OutliningDashboard novelId={novelId} />
<WorkDashboard novelId={novelId} />
)
}
function BrainstormingDashboard({ novel }: { novel: Novel }) {
const update = useUpdateNovel(novel.id)
const { can } = useAuth()
const canCreate = can('CreateContent', novel)
const [addingCharacter, setAddingCharacter] = useState(false)
const navigate = useNavigate()
return (
<div className="card p-5">
<h2 className="mb-1 text-sm font-semibold tracking-wide uppercase muted">Notes</h2>
<p className="mb-3 text-sm muted">
Premise, voice, scraps of scene, whatever's rattling around. Move to Outlining once
there's a shape to work from.
</p>
<AutoField
value={novel.notes}
multiline
rows={20}
serif
placeholder="Start anywhere."
onCommit={(notes) => update.mutate({ notes })}
readOnly={!can('Write', novel)}
/>
<div className="grid gap-6">
{canCreate && (
<section className="grid gap-3 sm:grid-cols-2">
<ActionCard
icon={IconPlus}
label="Add a character"
hint="Most outline questions resolve once you know who wants what."
onClick={() => setAddingCharacter(true)}
primary
/>
<ActionCard
icon={IconArrowRight}
label="Move to outlining"
hint="Once the shape is there, start turning notes into chapters."
onClick={() => update.mutate({ phase: 'Outlining' })}
/>
</section>
)}
<div className="card p-5">
<h2 className="mb-1 text-sm font-semibold tracking-wide uppercase muted">Notes</h2>
<p className="mb-3 text-sm muted">
Premise, voice, scraps of scene, whatever's rattling around. Move to Outlining once
there's a shape to work from.
</p>
<AutoField
value={novel.notes}
multiline
rows={20}
serif
placeholder="Start anywhere."
onCommit={(notes) => update.mutate({ notes })}
readOnly={!can('Write', novel)}
/>
</div>
{addingCharacter && (
<AddCharacterModal
novelId={novel.id}
onClose={() => setAddingCharacter(false)}
onCreated={(id) => navigate(`/novels/${novel.id}/characters/${id}`)}
/>
)}
</div>
)
}
function OutliningDashboard({ novelId }: { novelId: string }) {
function WorkDashboard({ novelId }: { novelId: string }) {
const { data: novel } = useNovel(novelId)
const { data: characters, isPending: charactersPending, error: charactersError } = useCharacters(novelId)
const { data: chapters, isPending: chaptersPending, error: chaptersError } = useChapters(novelId)
const { data: tags, isPending: tagsPending, error: tagsError } = useTags(novelId)
const { data: activity, isPending: activityPending, error: activityError } = useNovelActivity(novelId)
const { can } = useAuth()
const canCreate = can('CreateContent', novel)
const recentCharacters = [...(characters ?? [])].sort(
(a, b) => new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime(),
@@ -61,6 +98,8 @@ function OutliningDashboard({ novelId }: { novelId: string }) {
return (
<div className="grid gap-6">
{canCreate && <QuickActions novelId={novelId} lastChapter={recentChapters[0]} />}
<section id="dashboard-activity-graph" className="card p-5">
<h2 className="mb-4 text-lg font-semibold">Activity</h2>
@@ -174,6 +213,104 @@ function OutliningDashboard({ novelId }: { novelId: string }) {
)
}
function QuickActions({ novelId, lastChapter }: { novelId: string; lastChapter?: ChapterSummary }) {
const navigate = useNavigate()
const createChapter = useCreateChapter(novelId)
const [addingCharacter, setAddingCharacter] = useState(false)
return (
<section className="grid gap-3 sm:grid-cols-3">
<ActionCard
icon={IconPlus}
label="New chapter"
hint="Start with a title — the outline can come later."
onClick={() =>
createChapter.mutate(
{ title: 'Untitled chapter' },
{ onSuccess: (chapter) => navigate(`chapters/${chapter.id}`) },
)
}
busy={createChapter.isPending}
primary
/>
<ActionCard icon={IconCharacters} label="New character" hint="Add someone new to the cast." onClick={() => setAddingCharacter(true)} />
{lastChapter ? (
<ActionCard to={`chapters/${lastChapter.id}`} icon={IconChapter} label="Continue writing" hint={lastChapter.title} />
) : (
<ActionCard to="chapters" icon={IconChapter} label="View chapters" hint="Nothing drafted yet." />
)}
{createChapter.error && (
<div className="sm:col-span-3">
<ErrorNote error={createChapter.error} />
</div>
)}
{addingCharacter && (
<AddCharacterModal
novelId={novelId}
onClose={() => setAddingCharacter(false)}
onCreated={(id) => navigate(`characters/${id}`)}
/>
)}
</section>
)
}
function ActionCard({
icon: ActionIcon,
label,
hint,
onClick,
primary,
busy,
to,
}: {
icon: typeof IconPlus
label: string
hint: string
onClick?: () => void
primary?: boolean
busy?: boolean
to?: string
}) {
const className =
'card flex items-start gap-3 px-4 py-3.5 text-left transition hover:shadow-md disabled:cursor-not-allowed disabled:opacity-60'
const style = primary ? { borderColor: 'var(--accent)', background: 'var(--accent-soft)' } : undefined
const iconStyle = {
background: primary ? 'var(--accent)' : 'var(--surface-sunken)',
color: primary ? 'var(--accent-ink)' : 'var(--ink-muted)',
}
const content = (
<>
<span className="grid h-9 w-9 shrink-0 place-items-center rounded-lg" style={iconStyle}>
<ActionIcon />
</span>
<span className="min-w-0">
<span className="block font-semibold" style={primary ? { color: 'var(--accent)' } : undefined}>
{label}
</span>
<span className="block truncate text-sm muted">{hint}</span>
</span>
</>
)
if (to) {
return (
<Link to={to} className={className} style={style}>
{content}
</Link>
)
}
return (
<button type="button" className={className} style={style} onClick={onClick} disabled={busy}>
{content}
</button>
)
}
function TagCloud({ novelId, tags }: { novelId: string; tags: TagSummary[] }) {
const maxCount = Math.max(...tags.map((t) => t.totalCount), 1)
+156 -46
View File
@@ -1,29 +1,43 @@
import { Outlet, useParams, Link, NavLink, useNavigate } from 'react-router-dom'
import { useLogout, useNovel, useUpdateNovel } from '../api/hooks'
import { useState } from 'react'
import { Outlet, useParams, Link, NavLink, useLocation, useNavigate } from 'react-router-dom'
import { useChapter, useCharacters, useLogout, useNovel, useUpdateNovel } from '../api/hooks'
import { novelPhaseColor } from '../api/stage'
import { novelPhases } from '../api/types'
import { useAuth } from '../auth/AuthContext'
import { ErrorNote, Spinner } from '../components/ui'
import {
IconAgent,
IconChapters,
IconCharacters,
IconDashboard,
IconLocations,
IconSettings,
IconTags,
} from '../components/icons'
import { AgentPanel, type AgentContext } from '../components/AgentPanel'
import { HelpButton } from '../keyboard/HelpButton'
import { useHotkey } from '../keyboard/HotkeysContext'
import type { ComponentType, CSSProperties, SVGProps } from 'react'
const sections: { to: string; label: string; end?: boolean }[] = [
{ to: '', label: 'Dashboard', end: true },
{ to: 'chapters', label: 'Chapters' },
{ to: 'characters', label: 'Characters' },
{ to: 'tags', label: 'Tags' },
{ to: 'locations', label: 'Locations' },
{ to: 'agent', label: 'Agent' },
{ to: 'settings', label: 'Settings' },
const sections: { to: string; label: string; end?: boolean; icon: ComponentType<SVGProps<SVGSVGElement>> }[] = [
{ to: '', label: 'Dashboard', end: true, icon: IconDashboard },
{ to: 'chapters', label: 'Chapters', icon: IconChapters },
{ to: 'characters', label: 'Characters', icon: IconCharacters },
{ to: 'tags', label: 'Tags', icon: IconTags },
{ to: 'locations', label: 'Locations', icon: IconLocations },
{ to: 'settings', label: 'Settings', icon: IconSettings },
]
export default function NovelLayout() {
const { novelId = '' } = useParams()
const navigate = useNavigate()
const location = useLocation()
const { data: novel, isPending, error } = useNovel(novelId)
const update = useUpdateNovel(novelId)
const { user, can } = useAuth()
const canWrite = can('Write', novel)
const logout = useLogout()
const [agentOpen, setAgentOpen] = useState(false)
const goTo = (path: string) => navigate(path ? `/novels/${novelId}/${path}` : `/novels/${novelId}`)
@@ -32,74 +46,170 @@ export default function NovelLayout() {
useHotkey('g c', 'Go to characters', () => goTo('characters'), { group: 'Navigate' })
useHotkey('g t', 'Go to tags', () => goTo('tags'), { group: 'Navigate' })
useHotkey('g l', 'Go to locations', () => goTo('locations'), { group: 'Navigate' })
useHotkey('g a', 'Go to agent', () => goTo('agent'), { group: 'Navigate' })
useHotkey('g a', 'Toggle agent', () => setAgentOpen((o) => !o), { group: 'Navigate' })
useHotkey('g s', 'Go to settings', () => goTo('settings'), { group: 'Navigate' })
const segments = (location.pathname.split(`/novels/${novelId}/`)[1] ?? '').split('/')
const currentSegment = segments[0] ?? ''
const entityId = segments[1]
const currentSection = sections.find(({ to, end }) => (end ? currentSegment === '' : currentSegment === to))
const { data: chapterForContext } = useChapter(currentSegment === 'chapters' ? entityId : undefined)
const { data: charactersForContext } = useCharacters(novelId)
let agentContext: AgentContext | null = null
if (currentSegment === 'chapters' && chapterForContext) {
agentContext = { label: `Chapter — ${chapterForContext.title}` }
} else if (currentSegment === 'characters' && entityId) {
const character = charactersForContext?.find((c) => c.id === entityId)
if (character) agentContext = { label: `Character — ${character.name}` }
}
if (!agentContext && currentSection) {
agentContext = { label: currentSection.label }
}
const accentStyle = novel
? ({
'--accent': novelPhaseColor(novel.phase),
'--accent-soft': `color-mix(in srgb, ${novelPhaseColor(novel.phase)} 16%, transparent)`,
} as CSSProperties)
: undefined
return (
<div className="min-h-full">
<header className="sticky top-0 z-10 border-b" style={{ borderColor: 'var(--line)', background: 'var(--surface)' }}>
<div className="mx-auto flex max-w-[100rem] items-center gap-4 px-6 py-3">
<Link to="/" className="text-sm muted hover:underline">
Novels
</Link>
<Link to={`/novels/${novelId}`} className="truncate text-base font-semibold hover:underline">
<div className="flex min-h-full" style={accentStyle}>
<aside
id="novel-sidebar"
className="sticky top-0 flex h-screen w-60 shrink-0 flex-col"
style={{ background: 'var(--surface)', borderRight: '1px solid var(--line)' }}
>
<Link to="/" className="flex items-center gap-2 px-5 py-5" style={{ fontFamily: 'var(--font-display)' }}>
<span
className="grid h-6 w-6 shrink-0 place-items-center rounded-md text-xs font-bold"
style={{ background: 'var(--accent)', color: 'var(--accent-ink)' }}
>
N
</span>
<span className="text-lg font-semibold">Novelly</span>
</Link>
<div className="px-5 pb-5">
<Link
to={`/novels/${novelId}`}
className="block truncate text-base leading-snug font-semibold hover:opacity-85"
style={{ fontFamily: 'var(--font-display)' }}
>
{novel?.title ?? '…'}
</Link>
<div className="ml-auto flex items-center gap-3">
{novel && (
{novel && (
<div className="relative mt-2 inline-block">
<select
className="input w-auto"
id="novel-phase-select"
className="appearance-none rounded-full py-1 pr-6 pl-2.5 text-xs font-semibold tracking-wide"
value={novel.phase}
disabled={!canWrite}
onChange={(e) => update.mutate({ phase: e.target.value as (typeof novelPhases)[number] })}
aria-label="Novel phase"
style={{
background: 'color-mix(in srgb, var(--accent) 16%, transparent)',
color: 'var(--accent)',
border: '1px solid color-mix(in srgb, var(--accent) 35%, transparent)',
}}
>
{novelPhases.map((phase) => (
<option key={phase} value={phase}>
<option key={phase} value={phase} style={{ background: 'var(--surface)', color: 'var(--ink)' }}>
{phase}
</option>
))}
</select>
)}
<HelpButton />
{user && (
<>
<span className="truncate text-sm muted" title={user.email}>
{user.displayName} · {user.globalRole}
</span>
<button
className="btn"
onClick={() => logout.mutate(undefined, { onSuccess: () => navigate('/login') })}
>
Sign out
</button>
</>
)}
</div>
</div>
)}
</div>
<nav className="mx-auto flex max-w-[100rem] gap-1 px-6 pb-2 text-sm">
{sections.map(({ to, label, end }) => (
<nav className="space-y-0.5 px-3">
{sections.map(({ to, label, end, icon: SectionIcon }) => (
<NavLink
key={to}
to={to}
end={end}
className={({ isActive }) =>
`rounded-md px-3 py-1.5 font-medium transition ${isActive ? '' : 'muted hover:opacity-100'}`
`flex items-center gap-2.5 rounded-lg px-3 py-2 text-sm font-medium transition ${
isActive ? '' : 'muted hover:bg-[var(--surface-sunken)] hover:opacity-100'
}`
}
style={({ isActive }) =>
isActive ? { background: 'var(--accent-soft)', color: 'var(--accent)' } : undefined
}
>
<SectionIcon />
{label}
</NavLink>
))}
</nav>
</header>
<main className="mx-auto max-w-[100rem] px-6 py-8">
{error && <ErrorNote error={error} />}
{isPending ? <Spinner label="Loading novel" /> : <Outlet context={{ novelId }} />}
</main>
<div className="flex-1 px-3 pt-2">
<button
id="agent-toggle"
type="button"
className="flex w-full items-center gap-2.5 rounded-lg px-3 py-2 text-sm font-medium transition"
style={
agentOpen
? { background: 'var(--accent-soft)', color: 'var(--accent)' }
: { color: 'var(--accent)' }
}
onClick={() => setAgentOpen((o) => !o)}
>
<IconAgent />
Agent
<span className="ml-auto text-xs muted">g a</span>
</button>
</div>
<div className="px-3 pt-2 pb-4" style={{ borderTop: '1px solid var(--line)' }}>
{user && (
<div className="flex items-center gap-2 px-2 pt-3 pb-2">
<span
className="grid h-7 w-7 shrink-0 place-items-center rounded-full text-xs font-semibold"
style={{ background: 'var(--accent-soft)', color: 'var(--accent)' }}
>
{user.displayName.slice(0, 1).toUpperCase()}
</span>
<div className="min-w-0 flex-1">
<div className="truncate text-sm font-medium">{user.displayName}</div>
<div className="truncate text-xs muted">{user.globalRole}</div>
</div>
</div>
)}
<div className="flex items-center gap-2 px-2">
<HelpButton />
<button
className="btn flex-1 justify-center"
onClick={() => logout.mutate(undefined, { onSuccess: () => navigate('/login') })}
>
Sign out
</button>
</div>
</div>
</aside>
<div className="min-w-0 flex-1">
<header
className="sticky top-0 z-10 flex items-center gap-2 px-8 py-4 text-sm"
style={{ background: 'var(--canvas)' }}
>
<Link to="/" className="muted hover:opacity-100 hover:underline">
Novels
</Link>
<span className="muted">/</span>
<span className="font-medium">{currentSection?.label ?? '…'}</span>
</header>
<main className="mx-auto max-w-[100rem] px-8 pb-10">
{error && <ErrorNote error={error} />}
{isPending ? <Spinner label="Loading novel" /> : <Outlet context={{ novelId }} />}
</main>
</div>
<AgentPanel novelId={novelId} open={agentOpen} onClose={() => setAgentOpen(false)} context={agentContext} />
</div>
)
}
+1 -1
View File
@@ -121,7 +121,7 @@ function TagReferencePanel({
/>
</label>
<TagColorPicker
value={data.tag.color ?? '#9a4a2f'}
value={data.tag.color ?? '#7c5cff'}
readOnly={!canWrite}
onChange={(color) => update.mutate({ id: tagId, color })}
/>