feat: resolve all 22 items in Features and Bugs.txt and complete UI polish

This commit is contained in:
Schwitzz
2026-10-30 10:57:13 -05:00
parent 7214cee8cb
commit 4add429469
42 changed files with 6360 additions and 1805 deletions
+54 -6
View File
@@ -29,6 +29,13 @@ export interface SessionRow {
projectId: string;
title: string;
kind: AISession["kind"];
archived?: boolean | undefined;
agentId?: string | undefined;
providerId?: string | undefined;
model?: string | undefined;
messages?: AIMessage[] | undefined;
suggestions?: AISuggestion[] | undefined;
notes?: AINote[] | undefined;
createdAt: string;
updatedAt: string;
lastMessageAt: string | null;
@@ -64,7 +71,7 @@ export function createInMemoryPersistence(): AISessionPersistence {
}
/** A Dexie-backed IndexedDB persistence backend. */
export function createIndexedDBPersistence(dbName = "pubscript"): AISessionPersistence {
export function createIndexedDBPersistence(dbName = "pubscript_ai"): AISessionPersistence {
const db = new Dexie(dbName);
if (!db.tables.some((t) => t.name === "aiSessions")) {
db.version(1).stores({ aiSessions: "&id, projectId, updatedAt" });
@@ -110,9 +117,13 @@ export class AIStore {
projectId: row.projectId,
title: row.title,
kind: row.kind,
messages: [],
suggestions: [],
notes: [],
archived: row.archived ?? false,
agentId: row.agentId,
providerId: row.providerId,
model: row.model,
messages: row.messages ?? [],
suggestions: row.suggestions ?? [],
notes: row.notes ?? [],
createdAt: row.createdAt,
updatedAt: row.updatedAt,
lastMessageAt: row.lastMessageAt,
@@ -133,6 +144,13 @@ export class AIStore {
projectId: session.projectId,
title: session.title,
kind: session.kind,
archived: session.archived ?? false,
agentId: session.agentId,
providerId: session.providerId,
model: session.model,
messages: session.messages.map((m) => ({ ...m })),
suggestions: session.suggestions.map((s) => ({ ...s })),
notes: session.notes.map((n) => ({ ...n })),
createdAt: session.createdAt,
updatedAt: session.updatedAt,
lastMessageAt: session.lastMessageAt,
@@ -140,14 +158,21 @@ export class AIStore {
}
/** Create a new session and persist it. */
async createSession(title?: string): Promise<AISession> {
async createSession(
title?: string,
meta?: { agentId?: string; providerId?: string; model?: string; kind?: AISession["kind"] },
): Promise<AISession> {
await this.load();
const now = nowIso();
const session: AISession = {
id: createUlid(),
projectId: this.projectId,
title: title ?? `New AI session ${this.sessions.size + 1}`,
kind: "session",
kind: meta?.kind ?? "session",
archived: false,
agentId: meta?.agentId,
providerId: meta?.providerId,
model: meta?.model,
messages: [],
suggestions: [],
notes: [],
@@ -160,6 +185,29 @@ export class AIStore {
return this.clone(session);
}
/** Update session metadata (title, archived, agentId, providerId, model). */
async updateSessionMeta(
sessionId: string,
patch: {
title?: string;
archived?: boolean;
agentId?: string;
providerId?: string;
model?: string;
},
): Promise<AISession> {
return this.withTransaction(async () => {
const session = this.require(sessionId);
if (patch.title !== undefined) session.title = patch.title;
if (patch.archived !== undefined) session.archived = patch.archived;
if (patch.agentId !== undefined) session.agentId = patch.agentId;
if (patch.providerId !== undefined) session.providerId = patch.providerId;
if (patch.model !== undefined) session.model = patch.model;
session.updatedAt = nowIso();
return this.clone(session);
});
}
/** Get a session by id. */
async getSession(id: string): Promise<AISession | null> {
await this.load();
+19 -1
View File
@@ -123,6 +123,16 @@ export const storageLocal: StorageAdapter = {
return p;
},
async deleteProject(id: string): Promise<void> {
await db.projects.delete(id);
await db.books.where("projectId").equals(id).delete();
await db.chapters.where("projectId").equals(id).delete();
await db.entities.where("projectId").equals(id).delete();
await db.boards.where("projectId").equals(id).delete();
await db.plotBeats.where("projectId").equals(id).delete();
await db.aiSessions.where("projectId").equals(id).delete();
},
async createBook(b: { projectId: string; title: string }): Promise<Book> {
const id = createUlid();
const book: Book = { id, projectId: b.projectId, title: b.title, order: 0, chapters: [] };
@@ -140,8 +150,16 @@ export const storageLocal: StorageAdapter = {
return rows.sort((a, b) => a.order - b.order);
},
async deleteChapter(id: string): Promise<void> {
await db.chapters.delete(id);
},
async saveDocument(doc: DocumentRef): Promise<void> {
const existing = await db.chapters.get(doc.id);
const existingCount =
!existing && doc.bookId
? await db.chapters.where("bookId").equals(doc.bookId).count()
: 0;
const base: Chapter = existing ?? {
id: doc.id,
bookId: doc.bookId ?? "",
@@ -149,7 +167,7 @@ export const storageLocal: StorageAdapter = {
title: doc.title,
doc: doc.doc,
rev: 0,
order: 0,
order: existingCount,
meta: { wordCount: 0, scenes: [], updatedAt: nowIso() },
};
await db.chapters.put({
+2
View File
@@ -31,11 +31,13 @@ export interface StorageAdapter {
listProjects(): Promise<ProjectMeta[]>;
createProject(p: NewProject): Promise<Project>;
getProject(id: string): Promise<Project>;
deleteProject?(id: string): Promise<void>;
// books & chapters
createBook(b: { projectId: string; title: string }): Promise<Book>;
listBooks(projectId: string): Promise<Book[]>;
listChapters(bookId: string): Promise<Chapter[]>;
deleteChapter?(id: string): Promise<void>;
// content (debounced autosave)
saveDocument(doc: DocumentRef): Promise<void>;
+6
View File
@@ -19,6 +19,8 @@ export interface User {
email: string;
displayName: string;
role: Role;
/** Whether the account is locked out (auth + admin API access). */
disabled?: boolean | undefined;
/** Optional, never required in v1. */
passKey?: string;
createdAt: string;
@@ -313,6 +315,10 @@ export interface AISession {
projectId: string;
title: string;
kind: SessionKind;
archived?: boolean | undefined;
agentId?: string | undefined;
providerId?: string | undefined;
model?: string | undefined;
messages: AIMessage[];
suggestions: AISuggestion[];
notes: AINote[];
+5
View File
@@ -72,6 +72,11 @@ export function usePubScriptEditor(options: PubScriptEditorHookOptions = {}) {
const editor = useEditor({
editable,
extensions: pubScriptExtensions,
editorProps: {
attributes: {
class: "ps-editor-content",
},
},
...(content !== undefined ? { content } : {}),
...useEditorOptions,
});
+113 -2
View File
@@ -17,7 +17,14 @@
import StarterKit from "@tiptap/starter-kit";
import { TextStyle } from "@tiptap/extension-text-style";
import { Editor as TipTapEditor, getSchema, type Content } from "@tiptap/core";
import {
Editor as TipTapEditor,
Extension,
Mark,
getSchema,
mergeAttributes,
type Content,
} from "@tiptap/core";
import SceneBreakNode from "./nodes/SceneBreak.js";
import FootnoteInline from "./nodes/FootnoteInline.js";
@@ -37,6 +44,90 @@ import {
colorAttribute,
} from "./constants.js";
/**
* Inline highlight mark (`<mark class="ps-mark-highlight">`).
*/
export const HighlightMark = Mark.create({
name: "highlight",
parseHTML() {
return [{ tag: "mark" }];
},
renderHTML({ HTMLAttributes }) {
return ["mark", mergeAttributes({ class: "ps-mark-highlight" }, HTMLAttributes), 0];
},
});
/**
* Inline superscript mark (`<sup>`).
*/
export const SuperscriptMark = Mark.create({
name: "superscript",
excludes: "subscript",
parseHTML() {
return [{ tag: "sup" }];
},
renderHTML({ HTMLAttributes }) {
return ["sup", HTMLAttributes, 0];
},
});
/**
* Inline subscript mark (`<sub>`).
*/
export const SubscriptMark = Mark.create({
name: "subscript",
excludes: "superscript",
parseHTML() {
return [{ tag: "sub" }];
},
renderHTML({ HTMLAttributes }) {
return ["sub", HTMLAttributes, 0];
},
});
/**
* Block-level text alignment extension (`left` | `center` | `right` | `justify`)
* applied to paragraphs, headings, and blockquotes.
*/
export const TextAlignExtension = Extension.create({
name: "textAlign",
addGlobalAttributes() {
return [
{
types: ["heading", "paragraph", "blockquote"],
attributes: {
textAlign: {
default: "left",
parseHTML: (element: HTMLElement) => element.style.textAlign || "left",
renderHTML: (attributes: { textAlign?: string }) => {
if (!attributes.textAlign || attributes.textAlign === "left") {
return {};
}
return { style: `text-align: ${attributes.textAlign}` };
},
},
lineHeight: {
default: null,
parseHTML: (element: HTMLElement) => element.style.lineHeight || null,
renderHTML: (attributes: { lineHeight?: string | null }) => {
if (!attributes.lineHeight) return {};
return { style: `line-height: ${attributes.lineHeight}` };
},
},
textIndent: {
default: null,
parseHTML: (element: HTMLElement) => element.style.textIndent || null,
renderHTML: (attributes: { textIndent?: string | null }) => {
if (!attributes.textIndent) return {};
return { style: `text-indent: ${attributes.textIndent}` };
},
},
},
},
];
},
});
/**
* The full extension list, registered once. Order matters: base kits first,
* then text styling, then PubScript nodes.
@@ -44,8 +135,28 @@ import {
export const pubScriptExtensions = [
StarterKit,
TextStyle.extend({
addAttributes: colorAttribute,
addAttributes() {
return {
...colorAttribute(),
fontFamily: {
default: null,
parseHTML: (element: HTMLElement) => element.style.fontFamily || null,
renderHTML: (attributes: { fontFamily?: string | null }) =>
attributes.fontFamily ? { style: `font-family: ${attributes.fontFamily}` } : {},
},
fontSize: {
default: null,
parseHTML: (element: HTMLElement) => element.style.fontSize || null,
renderHTML: (attributes: { fontSize?: string | null }) =>
attributes.fontSize ? { style: `font-size: ${attributes.fontSize}` } : {},
},
};
},
}),
HighlightMark,
SuperscriptMark,
SubscriptMark,
TextAlignExtension,
SceneBreakNode,
FootnoteInline,
FootnoteArea,
+24 -4
View File
@@ -38,11 +38,31 @@ function buildRuns(text: string, marks: Mark[]): Run[] {
return runs;
}
function resolvePdfFont(fontFamily: string): "times" | "helvetica" | "courier" {
const lower = fontFamily.toLowerCase();
if (lower.includes("mono") || lower.includes("courier") || lower.includes("consolas")) {
return "courier";
}
if (
lower.includes("serif") ||
lower.includes("garamond") ||
lower.includes("georgia") ||
lower.includes("iowan") ||
lower.includes("times")
) {
return "times";
}
return "helvetica";
}
function setRunFont(doc: jsPDF, run: Run, theme: Theme): void {
const hasCode = run.marks.some((m) => m.type === "code");
const hasBold = run.marks.some((m) => m.type === "bold");
const hasItalic = run.marks.some((m) => m.type === "italic");
doc.setFont(hasCode ? "courier" : theme.fontFamily.split(",")[0]!.replace(/"/g, "").trim() || "helvetica", hasBold ? "bold" : hasItalic ? "italic" : "normal");
doc.setFont(
hasCode ? "courier" : resolvePdfFont(theme.fontFamily),
hasBold ? "bold" : hasItalic ? "italic" : "normal",
);
}
function pageGeometry() {
@@ -55,7 +75,7 @@ function pageGeometry() {
}
function newPage(doc: jsPDF, theme: Theme): void {
doc.setFont(theme.fontFamily.split(",")[0]!.replace(/"/g, "").trim() || "helvetica", "normal");
doc.setFont(resolvePdfFont(theme.fontFamily), "normal");
doc.setTextColor(hexToRgb(theme.colors.text));
doc.setFillColor(hexToRgb(theme.colors.background));
doc.rect(0, 0, doc.internal.pageSize.getWidth(), doc.internal.pageSize.getHeight(), "F");
@@ -111,7 +131,7 @@ function renderBlocks(
const level = Math.max(1, Math.min(6, block.level ?? 1));
const size = level === 1 ? 24 : level === 2 ? 18 : 14;
doc.setFontSize(size);
doc.setFont(theme.fontFamily.split(",")[0]!.replace(/"/g, "").trim() || "helvetica", "bold");
doc.setFont(resolvePdfFont(theme.fontFamily), "bold");
doc.setTextColor(hexToRgb(theme.colors.heading));
y += size * 0.9;
y = renderParagraph(doc, block.text ?? "", block.marks ?? [], margin, y, maxWidth, lineHeight * 1.2, theme);
@@ -120,7 +140,7 @@ function renderBlocks(
}
case "paragraph": {
doc.setFontSize(12);
doc.setFont(theme.fontFamily.split(",")[0]!.replace(/"/g, "").trim() || "helvetica", "normal");
doc.setFont(resolvePdfFont(theme.fontFamily), "normal");
doc.setTextColor(hexToRgb(theme.colors.text));
y += lineHeight;
y = renderParagraph(doc, block.text ?? "", block.marks ?? [], margin, y, maxWidth, lineHeight, theme);
File diff suppressed because it is too large Load Diff
+10 -6
View File
@@ -1,10 +1,11 @@
/**
* Font families per theme. See PLAN.md §4.7.
* System fonts only — no network fetches.
* UI chrome is locked to System Sans across all themes so typography changes
* only affect the writing editor surface.
*/
export interface FontSet {
/** UI chrome font. */
/** UI chrome font (locked to System Sans). */
ui: string;
/** Body / prose font. */
body: string;
@@ -12,24 +13,27 @@ export interface FontSet {
mono: string;
}
export const SYSTEM_UI_FONT =
"-apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif";
export const FONTS: Record<string, FontSet> = {
ink: {
ui: "-apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif",
ui: SYSTEM_UI_FONT,
body: "Georgia, 'Times New Roman', serif",
mono: "'SFMono-Regular', ui-monospace, 'Cascadia Code', Consolas, monospace",
},
parchment: {
ui: "Georgia, 'Times New Roman', serif",
ui: SYSTEM_UI_FONT,
body: "Georgia, 'Times New Roman', serif",
mono: "'SFMono-Regular', ui-monospace, Consolas, monospace",
},
nocturne: {
ui: "-apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif",
ui: SYSTEM_UI_FONT,
body: "Georgia, 'Times New Roman', serif",
mono: "'SFMono-Regular', ui-monospace, 'Cascadia Code', Consolas, monospace",
},
terminal: {
ui: "'SFMono-Regular', ui-monospace, 'Cascadia Code', Consolas, monospace",
ui: SYSTEM_UI_FONT,
body: "'SFMono-Regular', ui-monospace, Consolas, monospace",
mono: "'SFMono-Regular', ui-monospace, Consolas, monospace",
},
+12
View File
@@ -166,3 +166,15 @@ export function applyFontSettings(s: Partial<FontSettings>): void {
if (s.measure != null) root.style.setProperty("--ps-font-measure", `${s.measure}px`);
if (s.family != null && s.family !== "") root.style.setProperty("--ps-font-body", s.family);
}
/**
* Apply UI density ("compact" | "comfortable") by setting `data-density` on
* `:root` and updating `--ps-density` and `--ps-topbar-h`.
*/
export function applyDensity(density: "compact" | "comfortable"): void {
const root = typeof document !== "undefined" ? document.documentElement : undefined;
if (!root) return;
root.dataset.density = density;
root.style.setProperty("--ps-density", density === "compact" ? "8px" : "14px");
root.style.setProperty("--ps-topbar-h", density === "compact" ? "40px" : "48px");
}