CHUNK #3: Writing Surface — editor wiring, storage view, polish

Complete the Writing Surface chunk: wire the web writing view to the
offline-first storage adapter, finish the editor package exports, and
apply Step 4 polish (SVG monogram logo, real project in the sidebar,
font settings → CSS variables).

Editor package
- Re-export EditorContent + Editor type from @pubscript/editor so the
  web app never imports TipTap directly.

Web writing view (apps/web)
- project-store (zustand) over storageLocal: project, chapters, active
  chapter, autosave via createAutosave, idle detection via
  createIdleDetector.
- Client-side seed mirroring the server seed ("The Glass Harbor").
- WritingView component: editor + autosave label + word/scene count +
  chapter nav; wire the /write route inside AppShell.
- Import editor.css in main.tsx.

Core storage contract
- Extend StorageAdapter with createBook/listBooks/listChapters;
  implement in local + server adapters; update contract test mock.

Polish
- Sidebar: SVG monogram logo + live project title/word count instead
  of the placeholder "The Silent Cartographer".
- Fonts: applyFontSettings() in the theme engine writes --ps-font-*
  variables; .ps-content reads --ps-font-size/leading/measure; settings
  store applies fontSize on boot and change.

Docs
- PLAN.md: CHUNK #3 → in progress.
- CHUNK#3_PROGRESS.md: steps 1–3 done, step 4 polish in progress.

Verified: full workspace typecheck clean, lint clean, core 24 / ui 5 /
editor 7 tests pass, web build clean. (server vitest hits EADDRINUSE on
8080 — pre-existing Docker WordPress port conflict, unrelated.)
This commit is contained in:
Schwitzz
2026-10-25 13:57:07 -05:00
parent 9a965745ee
commit bb68d9d5d2
21 changed files with 804 additions and 74 deletions
+1
View File
@@ -13,6 +13,7 @@
},
"dependencies": {
"@pubscript/core": "workspace:*",
"@pubscript/editor": "workspace:*",
"@pubscript/ui": "workspace:*",
"react": "^19.0.0",
"react-dom": "^19.0.0",
+1 -1
View File
@@ -2,7 +2,7 @@ import { useState, useEffect } from "react";
import { Routes, Route } from "react-router-dom";
import { Sidebar, Topbar, CommandPalette } from "./components/index.js";
import { ToastViewport } from "@pubscript/ui";
import { Write } from "./routes/Write.js";
import Write from "./routes/Write.js";
import { Characters } from "./routes/Characters.js";
import { Plot } from "./routes/Plot.js";
import { Whiteboard } from "./routes/Whiteboard.js";
+37 -4
View File
@@ -1,4 +1,5 @@
import { NavLink } from "react-router-dom";
import { useProjectStore } from "../store/project-store.js";
export interface NavItem {
to: string;
@@ -11,19 +12,51 @@ export interface SidebarProps {
nav: NavItem[];
}
/** A minimal view of a chapter document for word counting. */
interface ChapterDoc {
doc: { content?: Array<{ text?: string }> };
}
/** Total word count across every chapter document in the active project. */
function totalWords(chapters: ChapterDoc[]): number {
let total = 0;
for (const chapter of chapters) {
for (const node of chapter.doc.content ?? []) {
if (node.text) total += node.text.trim().split(/\s+/).filter(Boolean).length;
}
}
return total;
}
export function Sidebar({ nav }: SidebarProps) {
const project = useProjectStore((s) => s.project);
const chapters = useProjectStore((s) => s.chapters);
const title = project?.title ?? "Untitled project";
const wordCount = totalWords(chapters);
return (
<aside className="ps-sidebar">
<div className="ps-logo">
<span className="ps-logo-mark">✒</span> PubScript
<svg className="ps-logo-mark" viewBox="0 0 24 24" fill="none" aria-hidden="true">
<path
d="M4 16c1.5-1.5 3-2 4.5-1.5L16 6l3 3-6.5 6.5c.5 1.5 0 3-1.5 4-.5.5-1.2.7-1.8.5-.6-.2-1-.7-1-1.4 0-.6.2-1.2.8-1.8L14 9l-2-2-3.5 3.5C7.5 9.5 5 10 4 16Z"
stroke="currentColor"
strokeWidth="1.6"
strokeLinejoin="round"
/>
<path d="M14.5 6.5l3 3" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" />
</svg>
PubScript
</div>
<div className="ps-nav-section-title">Project</div>
<button className="ps-nav-item">
<button className="ps-nav-item" title={title}>
<span className="ps-nav-icon">📖</span>
<span>
<div>The Silent Cartographer</div>
<div style={{ fontSize: 12, opacity: 0.6 }}>12 chapters · 47k words</div>
<div>{title}</div>
<div style={{ fontSize: 12, opacity: 0.6 }}>
{chapters.length} chapter{chapters.length === 1 ? "" : "s"} · {(Math.round(wordCount / 100) * 100).toLocaleString()} words
</div>
</span>
</button>
+139
View File
@@ -0,0 +1,139 @@
/**
* WritingView — the editor surface for a single chapter.
*
* Wires the TipTap editor (`packages/editor`) to the project store:
* - loads the active chapter's document into the editor,
* - syncs content when the chapter changes,
* - debounces every edit through the storage adapter via `scheduleSave`,
* - shows word count, scene count, and an autosave indicator.
*/
import { useEffect } from "react";
import { EditorContent, usePubScriptEditor, type Editor, SCENE_BREAK } from "@pubscript/editor";
import type { DocumentRef, AutosaveState } from "@pubscript/core";
import { useProjectStore } from "../store/project-store.js";
/** Count words in the editor document. */
function countWords(editor: Editor | null): number {
if (!editor) return 0;
const text = editor.state.doc.textContent;
if (!text.trim()) return 0;
return text.trim().split(/\s+/).filter(Boolean).length;
}
/** Count occurrences of a node type in the editor document. */
function countNode(editor: Editor | null, nodeType: string): number {
if (!editor) return 0;
let count = 0;
editor.state.doc.forEach((node) => {
if (node.type.name === nodeType) count += 1;
});
return count;
}
/** Format the autosave status for the indicator. */
function autosaveLabel(status: AutosaveState): string {
if (status.saving) return "Saving…";
if (status.dirty) return "Unsaved changes";
if (!status.lastSavedAt) return "Not saved yet";
const seconds = Math.max(0, Math.round((Date.now() - new Date(status.lastSavedAt).getTime()) / 1000));
return `Saved ${seconds}s ago`;
}
interface WritingViewProps {
/** Fired when the user picks a different chapter from the nav. */
onChapterChange?: (id: string) => void;
}
export function WritingView({ onChapterChange }: WritingViewProps) {
const project = useProjectStore((s) => s.project);
const chapters = useProjectStore((s) => s.chapters);
const activeChapterId = useProjectStore((s) => s.activeChapterId);
const selectChapter = useProjectStore((s) => s.selectChapter);
const scheduleSave = useProjectStore((s) => s.scheduleSave);
const flushAutosave = useProjectStore((s) => s.flushAutosave);
const autosave = useProjectStore((s) => s.autosave);
const chapter = chapters.find((c) => c.id === activeChapterId) ?? null;
const projectId = project?.id ?? null;
const { editor } = usePubScriptEditor({
...(chapter ? { content: chapter.doc.content } : {}),
onUpdate: (ed) => {
if (!chapter || !projectId) return;
const json = ed.getJSON().content;
const ref: DocumentRef = {
id: chapter.id,
projectId,
title: chapter.title,
doc: { type: "doc", content: json },
rev: chapter.rev,
};
scheduleSave(ref);
},
});
// Sync content when the active chapter changes. emitUpdate:false keeps this
// from re-entering onUpdate (which would otherwise re-save what we just loaded).
useEffect(() => {
if (!editor || !chapter) return;
editor.commands.setContent(chapter.doc.content, { emitUpdate: false });
editor.commands.focus();
}, [editor, chapter?.id]);
// Flush pending edits when the user closes or navigates away.
useEffect(() => {
const onBeforeUnload = () => flushAutosave();
window.addEventListener("beforeunload", onBeforeUnload);
return () => window.removeEventListener("beforeunload", onBeforeUnload);
}, [flushAutosave]);
if (!project) {
return <div className="ps-emptystate">No project yet.</div>;
}
if (!chapter) {
return <div className="ps-emptystate">This project has no chapters.</div>;
}
const wordCount = countWords(editor);
const sceneCount = countNode(editor, SCENE_BREAK);
return (
<div className="ps-writing">
<div className="ps-writing-toolbar">
<label className="ps-chapter-select">
<span className="ps-chapter-select-label">Chapter</span>
<select
value={activeChapterId ?? ""}
onChange={(e) => {
const id = e.target.value;
void selectChapter(id);
onChapterChange?.(id);
}}
>
{chapters.map((c, i) => (
<option key={c.id} value={c.id}>
{c.title || `Chapter ${i + 1}`}
</option>
))}
</select>
</label>
<div className="ps-autosave-indicator" data-status={autosave.saving ? "saving" : autosave.dirty ? "dirty" : "saved"}>
<span className="ps-autosave-dot" />
<span>{autosaveLabel(autosave)}</span>
</div>
</div>
<div className="ps-content ps-editor-writing">
<div className="ps-editor">
<EditorContent editor={editor} />
</div>
</div>
<div className="ps-writing-status">
<span>{wordCount.toLocaleString()} words</span>
<span>{sceneCount} scene{sceneCount === 1 ? "" : "s"}</span>
<span className="ps-writing-status-note">{chapter.title}</span>
</div>
</div>
);
}
+2
View File
@@ -2,6 +2,8 @@ import { StrictMode } from "react";
import { createRoot } from "react-dom/client";
import { BrowserRouter } from "react-router-dom";
import App from "./App.js";
import "@pubscript/editor/styles";
import "./styles/writing.css";
createRoot(document.getElementById("root")!).render(
<StrictMode>
+14 -57
View File
@@ -1,65 +1,22 @@
import { useMemo } from "react";
import { useSettings } from "../store/settings.js";
import { useEffect } from "react";
import { WritingView } from "../components/WritingView.js";
import { useProjectStore } from "../store/project-store.js";
export function Write() {
const wordCountGoal = useSettings((s) => s.wordCountGoal);
const showLineNumbers = useSettings((s) => s.showLineNumbers);
export default function Write() {
const init = useProjectStore((s) => s.init);
const status = useProjectStore((s) => s.status);
const content = `# Chapter One\n\nThe harbor mist clung to the docks like a held breath. Mara set down her satchel and watched the gulls wheel against the grey sky…\n\n(This is a placeholder for the ProseMirror editor. CHUNK #1 establishes the shell; the rich-text editor lands in a later chunk.)`;
const lines = useMemo(() => content.split("\n"), [content]);
const words = useMemo(() => content.trim().split(/\s+/).filter(Boolean).length, [content]);
const pct = Math.min(100, Math.round((words / wordCountGoal) * 100));
useEffect(() => {
void init();
}, [init]);
return (
<div className="ps-content">
<input
type="text"
defaultValue="Chapter One"
style={{
fontSize: 28,
fontWeight: 700,
border: "none",
background: "transparent",
color: "var(--ps-text)",
marginBottom: 16,
width: "100%",
}}
/>
{showLineNumbers && (
<div style={{ display: "flex", textAlign: "right", color: "var(--ps-text-muted)", userSelect: "none", paddingRight: 8, marginBottom: 4 }}>
{lines.map((_, i) => (
<span key={i} style={{ fontFamily: "var(--ps-font-mono)", fontSize: 12 }}>{i + 1}</span>
))}
</div>
<div className="ps-writing-slot">
{status === "loading" ? (
<div className="ps-emptystate">Loading project…</div>
) : (
<WritingView />
)}
<textarea
defaultValue={content}
spellCheck={false}
style={{
width: "100%",
minHeight: 480,
resize: "vertical",
background: "var(--ps-surface)",
color: "var(--ps-text)",
border: "1px solid var(--ps-border)",
borderRadius: 8,
padding: 16,
fontFamily: "var(--ps-font-body)",
fontSize: 17,
lineHeight: 1.75,
}}
/>
<div style={{ display: "flex", alignItems: "center", gap: 12, marginTop: 16, fontSize: 13, color: "var(--ps-text-muted)" }}>
<span>{words.toLocaleString()} words</span>
<div style={{ flex: 1, height: 6, background: "var(--ps-surface-2)", borderRadius: 3, overflow: "hidden" }}>
<div style={{ width: `${pct}%`, height: "100%", background: "var(--ps-accent)", transition: "width 0.3s" }} />
</div>
<span>{pct}% of {wordCountGoal.toLocaleString()} goal</span>
</div>
</div>
);
}
+104
View File
@@ -0,0 +1,104 @@
/**
* Project store — the single source of truth for the writing experience.
*
* Wraps a `StorageAdapter` (offline-first `storageLocal` by default) and owns:
* - the active project and its chapters,
* - the active chapter,
* - the autosave engine (`createAutosave`) that persists edits.
*
* The editor (`WritingView`) reads the active chapter and calls `scheduleSave`
* on every update; the store debounces the writes through the adapter.
*/
import {
createAutosave,
type AutosaveState,
type Chapter,
type DocumentRef,
type ProjectMeta,
type StorageAdapter,
} from "@pubscript/core";
import { storageLocal } from "@pubscript/core/local";
import { create } from "zustand";
import { seedDemoProject } from "./seed.js";
interface ProjectStore {
// --- state ---
adapter: StorageAdapter | null;
project: ProjectMeta | null;
chapters: Chapter[];
activeChapterId: string | null;
status: "idle" | "loading" | "ready";
error: string | null;
autosave: AutosaveState;
// --- actions ---
/** Load the project (seed a demo if empty) and select the first chapter. */
init: () => Promise<void>;
/** Switch the active chapter, flushing any pending edits first. */
selectChapter: (id: string) => Promise<void>;
/** Queue a debounced save of a chapter document. */
scheduleSave: (ref: DocumentRef) => void;
/** Flush any pending save immediately (beforeunload / chapter switch). */
flushAutosave: () => void;
}
export const useProjectStore = create<ProjectStore>((set) => {
let autosave: ReturnType<typeof createAutosave> | null = null;
return {
adapter: null,
project: null,
chapters: [],
activeChapterId: null,
status: "idle",
error: null,
autosave: { dirty: false, saving: false, lastSavedAt: null, status: "saved" },
init: async () => {
set({ status: "loading", error: null });
try {
const adapter = storageLocal;
let projects = await adapter.listProjects();
if (projects.length === 0) {
await seedDemoProject(adapter);
projects = await adapter.listProjects();
}
if (projects.length === 0) {
throw new Error("No projects found.");
}
const project = projects[0]!;
const books = await adapter.listBooks(project.id);
const book = books[0];
const chapters = book ? await adapter.listChapters(book.id) : [];
autosave = createAutosave(adapter);
autosave.onStatus((status) => set({ autosave: status }));
const activeChapterId = chapters.length > 0 ? chapters[0]!.id : null;
set({ adapter, project, chapters, activeChapterId, status: "ready" });
} catch (e) {
set({ error: e instanceof Error ? e.message : String(e), status: "ready" });
}
},
selectChapter: async (id) => {
// Flush pending edits so switching chapters never drops unsaved work.
if (autosave) {
await autosave.flush();
}
set({ activeChapterId: id });
},
scheduleSave: (ref) => {
if (autosave) {
autosave.save(ref);
}
},
flushAutosave: () => {
if (autosave) {
void autosave.flush();
}
},
};
});
+199
View File
@@ -0,0 +1,199 @@
/**
* Client-side seed for the offline-first local adapter.
*
* The server `seed.ts` populates SQLite; this mirrors it for IndexedDB so the
* browser app has the same demo project ("The Glass Harbor") to explore the
* editor against on first run. Idempotent: the store only seeds when no
* projects exist yet.
*/
import {
createUlid,
type Board,
type Character,
type ProseMirrorDoc,
type WorldItem,
type StorageAdapter,
} from "@pubscript/core";
/** A single ProseMirror paragraph node. */
function para(text: string): ProseMirrorDoc["content"][0] {
return { type: "paragraph", content: [{ type: "text", text }] };
}
/** Build a ProseMirror doc from an array of paragraph texts. */
function doc(paras: string[]): ProseMirrorDoc {
return { type: "doc", content: paras.map(para) };
}
const CHAPTERS: { title: string; paras: string[] }[] = [
{
title: "Chapter One — The Arrival",
paras: [
"The ferry that carried Mara Vell to the Glass Harbor left the mainland before dawn, and by the time it came to rest the sea had turned the color of a bruised plum.",
"She pressed her palm against the cold window and watched the lighthouse appear out of the mist — a thin white line standing where the land gave up to the water.",
"Three years since she had last seen it, and yet it seemed to have shrunk, or perhaps she had simply grown too large for the memory of it.",
"The harbor master waited for her on the quay, wrapped in oilskin against a wind that did not so much blow as insist. He did not wave. He never waved.",
'"You came," he said, as if to say he had not expected her, and had been wrong about it all morning.',
'"The light is failing," he added. "We have had three nights without it. The fish are turning away."',
],
},
{
title: "Chapter Two — The Storm",
paras: [
"The storm arrived on the second night, and it came the way everything at the Glass Harbor came — without apology and without warning.",
"Mara climbed the one hundred and fourteen steps to the lamp, each one slick with salt spray, and found that the mechanism had seized with the same stubborn rust that had seized everything else on this rock.",
"She worked through the small hours with a wrench and her own two hands, coaxing the gears until at last the great beam swung free and threw its light out across the black water.",
"Below her, in the harbor, the boats threw their lines and danced on them, and she thought of all the sailors who had been lost here when the light was dead.",
'"Four ships in ten years," said a voice behind her. It was Old Fen, the hermit, half-frozen and grinning as if the storm were a joke only he understood.',
'"They did not die because the light failed," Fen said. "They died because they forgot that someone had to keep it."',
],
},
{
title: "Chapter Three — The Glass in the Dark",
paras: [
"In the morning the sea lay flat and exhausted, strewn with the kelp and driftwood the storm had disgorged.",
"Mara walked the shoreline and found, half-buried in the wrack, a shard of glass the size of her palm — but the glass was warm, and it held inside it a light that did not belong to the rising sun.",
"This was the secret the Glass Harbor had kept for a hundred years: the light of the lighthouse was not made, it was mined — drawn up from the deep in shards of living glass that only a keeper knew how to kindle.",
"She understood then why the old keeper had never waved, and why he had never left. Some duties are not chosen. They are inherited, like the tide.",
"When Tomas Reed returned from his rounds that evening, he found Mara at the lamp, her hands on the warm glass, and he stopped in the doorway with the old grief on his face.",
'"I can teach you," he said. "If you want it. No one has ever wanted it before."',
"Mara looked out at the water, at the fragile line of the quay, at the little lights of the town that had welcomed her without asking why she had come.",
'"I want it," she said, and the light answered for her.',
],
},
];
const CHARACTERS: Character[] = [
{
id: "char-mara-vell",
projectId: "",
name: "Mara Vell",
role: "The New Keeper",
bio: "A cartographer who returned to the Glass Harbor she fled three years ago, carrying a grief she has never named. Precise, stubborn, and quietly afraid of being needed.",
appearance: "Late thirties, salt-short hair, salt-calloused hands, a scar through one eyebrow from a rigging line she did not remember cutting herself on.",
voice: "Dry, understated, speaks like someone counting the cost of every word.",
relationships: [
{ targetId: "char-tomas-reed", kind: "family", note: "Uncle; the keeper before her." },
{ targetId: "char-old-fen", kind: "mentor", note: "Taught her to swim in waters too cold for children." },
],
arc: "From someone running from duty to someone who chooses to keep the light.",
status: "active",
tags: ["protagonist", "keeper"],
},
{
id: "char-tomas-reed",
projectId: "",
name: "Tomas Reed",
role: "The Harbor Master",
bio: "The keeper of the quay and the keeper of the light before Mara. Carries the old grief of a storm he could not save. Does not wave because he believes grief is a full-time job.",
appearance: "Sixties, weathered like driftwood, one leg that aches before a storm.",
voice: "Few words. Speaks in the short sentences of a man who has learned words are expensive.",
relationships: [
{ targetId: "char-mara-vell", kind: "family", note: "Niece; the keeper he is training." },
{ targetId: "char-old-fen", kind: "ally", note: "Oldest friend and only confidant." },
],
arc: "From guarding a secret alone to trusting someone to inherit it.",
status: "active",
tags: ["mentor", "keeper"],
},
{
id: "char-iris-kade",
projectId: "",
name: "Iris Kade",
role: "The Smuggler",
bio: "Runs the supply boat between the harbor and the mainland, carrying glass as carefully as she carries secrets. Witty, generous, and impossible to trust with a straight face.",
appearance: "Forties, sun-leathered, a silver ring through each ear, always barefoot even in winter.",
voice: "Fast, warm, teasing. Lies beautifully and enjoys it.",
relationships: [
{ targetId: "char-mara-vell", kind: "ally", note: "Funds the lighthouse quietly; becomes Mara's ally on the mainland." },
{ targetId: "char-tomas-reed", kind: "rival", note: "Clashes over how to keep the harbor alive." },
],
arc: "From a self-interested survivor to someone with a place to belong.",
status: "active",
tags: ["ally", "rival"],
},
{
id: "char-old-fen",
projectId: "",
name: "Old Fen",
role: "The Hermit",
bio: "Lives in a cottage where the land gives up to the water. Has seen four ships lost and never once said how he knew it was coming. Everyone fears him and loves him in the same breath.",
appearance: "Ageless, thin, eyes the pale gray of the sea before a storm.",
voice: "Speaks in riddles that turn out to be warnings. Grins at the wrong moments.",
relationships: [
{ targetId: "char-mara-vell", kind: "mentor", note: "The child he taught to swim; the keeper he is guiding." },
{ targetId: "char-tomas-reed", kind: "ally", note: "Oldest friend; the only one who understands him." },
],
arc: "From keeper of impossible knowledge to the one who finally tells the truth.",
status: "active",
tags: ["mysterious", "hermit"],
},
];
const WORLD: WorldItem = {
id: "world-glass-harbor",
projectId: "",
kind: "location",
name: "The Glass Harbor",
fields: {
kind: "location",
description:
"A fishing village on a wind-battered rock at the edge of the mainland shelf, lit by a lighthouse whose beam is fed by shards of living glass drawn from the deep.",
population: "Under two hundred",
economy: "Fishing, and the quiet, illegal trade in harbor-glass that keeps the light alive.",
notable:
"The Lighthouse (one hundred and fourteen steps to the lamp) · The Quay (where Tomas waits and Iris unloads her cargo) · The Wrack Line (where the sea deposits what the storm has taken).",
},
};
const BOARD: Board = {
id: "board-story",
projectId: "",
nodes: [
{ id: "n1", type: "note", position: { x: 80, y: 120 }, data: { text: "Mara arrives at the harbor" } },
{ id: "n2", type: "note", position: { x: 320, y: 120 }, data: { text: "The light is failing" } },
{ id: "n3", type: "note", position: { x: 560, y: 120 }, data: { text: "The storm arrives" } },
{ id: "n4", type: "note", position: { x: 800, y: 120 }, data: { text: "Mara saves the light" } },
{ id: "n5", type: "note", position: { x: 320, y: 320 }, data: { text: "The secret of the glass" } },
{ id: "n6", type: "note", position: { x: 560, y: 320 }, data: { text: "Mara chooses the duty" } },
],
edges: [
{ id: "e1", source: "n1", target: "n2", type: "leads-to" },
{ id: "e2", source: "n2", target: "n3", type: "leads-to" },
{ id: "e3", source: "n3", target: "n4", type: "leads-to" },
{ id: "e4", source: "n4", target: "n5", type: "causes" },
{ id: "e5", source: "n5", target: "n6", type: "leads-to" },
],
};
/**
* Seed the adapter with the demo project if it has no projects yet.
* Returns the created project id.
*/
export async function seedDemoProject(adapter: StorageAdapter): Promise<string> {
const project = await adapter.createProject({
title: "The Glass Harbor",
cover: { color: "#0f3d3e" },
});
const book = await adapter.createBook({ projectId: project.id, title: "Book One" });
for (const chapter of CHAPTERS) {
await adapter.saveDocument({
id: createUlid(),
projectId: project.id,
bookId: book.id,
title: chapter.title,
doc: doc(chapter.paras),
rev: 0,
});
}
for (const character of CHARACTERS) {
await adapter.saveEntity({ ...character, projectId: project.id });
}
await adapter.saveEntity({ ...WORLD, projectId: project.id });
await adapter.saveBoard({ ...BOARD, projectId: project.id });
return project.id;
}
+5 -2
View File
@@ -1,5 +1,5 @@
import { create } from "zustand";
import { applyTheme, type ThemeId } from "@pubscript/ui";
import { applyFontSettings, applyTheme, type ThemeId } from "@pubscript/ui";
export type Density = "compact" | "comfortable";
@@ -44,14 +44,16 @@ export interface SettingsStore extends AppSettings {
export const useSettings = create<SettingsStore>((set, get) => {
const initial = loadSettings();
// Apply the persisted theme on boot.
// Apply the persisted theme + font on boot.
applyTheme(initial.theme);
applyFontSettings({ size: initial.fontSize });
return {
...initial,
set: (patch) => {
const next = { ...get(), ...patch };
localStorage.setItem(STORAGE_KEY, JSON.stringify(next));
if (patch.fontSize != null) applyFontSettings({ size: next.fontSize });
set(next);
},
setTheme: (theme) => {
@@ -61,6 +63,7 @@ export const useSettings = create<SettingsStore>((set, get) => {
reset: () => {
localStorage.removeItem(STORAGE_KEY);
applyTheme(DEFAULT_SETTINGS.theme);
applyFontSettings({ size: DEFAULT_SETTINGS.fontSize });
set({ ...DEFAULT_SETTINGS });
},
};
+161
View File
@@ -0,0 +1,161 @@
/**
* Writing-view styles (apps/web).
*
* The editor's own nodes are styled by @pubscript/editor/styles (scoped to
* `.ps-editor`). This file styles the writing surface around it: the toolbar,
* the autosave indicator, the chapter selector, and the status bar. The prose
* column itself reuses `.ps-content` from the AppShell stylesheet.
*/
/* Fills the ps-main slot (flex: 1, overflow: hidden) with a scroll region. */
.ps-writing-slot {
height: 100%;
overflow: auto;
}
/* Toolbar: chapter selector left, autosave indicator right. */
.ps-writing-toolbar {
position: sticky;
top: 0;
z-index: 5;
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
min-height: 44px;
padding: 6px 24px;
background: color-mix(in srgb, var(--ps-surface) 88%, transparent);
backdrop-filter: blur(8px);
border-bottom: 1px solid var(--ps-border);
}
/* Chapter selector. */
.ps-chapter-select {
display: inline-flex;
align-items: center;
gap: 8px;
}
.ps-chapter-select-label {
font-size: 12px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.06em;
color: var(--ps-text-muted);
}
.ps-chapter-select select {
appearance: none;
font: inherit;
font-size: 14px;
color: var(--ps-text);
background: var(--ps-surface-2);
border: 1px solid var(--ps-border);
border-radius: 8px;
padding: 6px 30px 6px 10px;
cursor: pointer;
max-width: 320px;
transition: border-color 0.15s ease, background 0.15s ease;
background-image: linear-gradient(45deg, transparent 50%, var(--ps-text-muted) 50%),
linear-gradient(135deg, var(--ps-text-muted) 50%, transparent 50%);
background-position: calc(100% - 16px) calc(0.6em), calc(100% - 11px) calc(0.6em);
background-size: 5px 5px, 5px 5px;
background-repeat: no-repeat;
}
.ps-chapter-select select:hover {
background-color: var(--ps-border);
}
.ps-chapter-select select:focus {
outline: none;
border-color: var(--ps-accent);
box-shadow: 0 0 0 3px color-mix(in srgb, var(--ps-accent) 30%, transparent);
}
/* Autosave indicator. */
.ps-autosave-indicator {
display: inline-flex;
align-items: center;
gap: 7px;
font-size: 12.5px;
font-variant-numeric: tabular-nums;
color: var(--ps-text-muted);
padding: 4px 10px;
border-radius: 999px;
background: var(--ps-surface-2);
border: 1px solid var(--ps-border);
white-space: nowrap;
}
.ps-autosave-dot {
width: 7px;
height: 7px;
border-radius: 50%;
background: var(--ps-text-muted);
opacity: 0.5;
flex: none;
}
.ps-autosave-indicator[data-status="saved"] .ps-autosave-dot {
background: var(--ps-success);
opacity: 1;
}
.ps-autosave-indicator[data-status="dirty"] .ps-autosave-dot {
background: var(--ps-warning);
opacity: 1;
}
.ps-autosave-indicator[data-status="saving"] .ps-autosave-dot {
background: var(--ps-accent);
opacity: 1;
animation: ps-pulse 1s ease-in-out infinite;
}
@keyframes ps-pulse {
0%, 100% { transform: scale(1); opacity: 1; }
50% { transform: scale(1.5); opacity: 0.5; }
}
/* Status bar: word count, scene count, chapter title. */
.ps-writing-status {
position: sticky;
bottom: 0;
z-index: 5;
display: flex;
align-items: center;
gap: 16px;
padding: 6px 24px;
font-size: 12px;
color: var(--ps-text-muted);
background: color-mix(in srgb, var(--ps-surface) 88%, transparent);
backdrop-filter: blur(8px);
border-top: 1px solid var(--ps-border);
}
.ps-writing-status .ps-writing-status-note {
margin-inline-start: auto;
font-style: italic;
opacity: 0.8;
}
/* The editor column: reuse the prose column from the AppShell. */
.ps-editor-writing {
padding-top: 28px;
padding-bottom: 80px;
}
.ps-editor-writing .ps-editor {
min-height: 60vh;
}
/* Placeholder text inside the editor when it's empty. */
.ps-editor .ProseMirror p.is-editor-empty::before {
content: attr(data-placeholder);
color: var(--ps-text-muted);
opacity: 0.5;
float: left;
pointer-events: none;
height: 0;
}