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
+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;
}