Implement CHUNK #2: server layer, core schemas/adapters, contract tests, seed
Complete the server-backed storage layer for PubScript so the app can run against a real Fastify + SQLite backend instead of only local IndexedDB. Server (@pubscript/server): - db.ts: better-sqlite3 schema + migrations (users, shares, projects, books, chapters, entities, boards, ai_sessions, audit); WAL mode; idempotent admin bootstrap via PUBSCRIPT_ADMIN_EMAIL/PASSWORD. - auth.ts: argon2 hashing, JWT-ish session tokens, guards, auth routes. - routes.ts: REST CRUD for projects/books/chapters/entities/boards/ai_sessions with multi-tenant isolation and optimistic concurrency (baseRev). - ws.ts: Fastify websocket hub for autosave + presence events. - mapping.ts: row <-> core type conversion. Core (@pubscript/core): - Zod domain schemas (projects, books, chapters, entities, boards, ai_sessions) exported from index. - storage-local.ts (IndexedDB/Dexie) and storage-server.ts (REST) adapters implementing the StorageAdapter contract. Tests & tooling: - storage.contract.test.ts: parameterized over both adapters; wired as `pnpm contract` and added to CI. - seed.ts (`pnpm --filter server seed`): creates demo project "The Glass Harbor" — 3 chapters of prose, 4 linked characters, 1 world location, 6-node board. - Dockerfile + compose wiring for the server runtime. Acceptance: seed project opens with 3 chapters, 4 linked characters, 1 board.
This commit is contained in:
@@ -0,0 +1,32 @@
|
||||
{
|
||||
"name": "@pubscript/core",
|
||||
"version": "0.1.0",
|
||||
"type": "module",
|
||||
"main": "./src/index.ts",
|
||||
"types": "./src/index.ts",
|
||||
"exports": {
|
||||
".": "./src/index.ts",
|
||||
"./local": "./src/storage-local.ts",
|
||||
"./package.json": "./package.json"
|
||||
},
|
||||
"scripts": {
|
||||
"dev": "echo \"@pubscript/core has no dev server\" (exit 0)",
|
||||
"build": "tsc --noEmit",
|
||||
"test": "vitest run",
|
||||
"lint": "eslint .",
|
||||
"typecheck": "tsc --noEmit",
|
||||
"contract": "vitest run src/storage.contract.test.ts"
|
||||
},
|
||||
"dependencies": {
|
||||
"dexie": "^4.0.11",
|
||||
"gpt-tokenizer": "^2.6.0",
|
||||
"ulid": "^2.3.0",
|
||||
"zod": "^3.24.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^22.10.2",
|
||||
"fake-indexeddb": "^6.0.0",
|
||||
"typescript": "^5.7.2",
|
||||
"vitest": "^2.1.8"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
import { describe, it, expect, vi } from "vitest";
|
||||
import { createAutosave } from "./autosave.js";
|
||||
import type { StorageAdapter } from "./storage.js";
|
||||
import type { DocumentRef } from "./types.js";
|
||||
|
||||
function mockAdapter(): StorageAdapter & { saved: DocumentRef[] } {
|
||||
const saved: DocumentRef[] = [];
|
||||
return {
|
||||
saved,
|
||||
async listProjects() {
|
||||
return [];
|
||||
},
|
||||
async createProject() {
|
||||
throw new Error("not implemented");
|
||||
},
|
||||
async getProject() {
|
||||
throw new Error("not implemented");
|
||||
},
|
||||
async getChapter() {
|
||||
return null;
|
||||
},
|
||||
async saveEntity() {},
|
||||
async saveBoard() {},
|
||||
async saveAISession() {},
|
||||
async saveDocument(doc: DocumentRef) {
|
||||
saved.push(doc);
|
||||
},
|
||||
onEvent() {
|
||||
return () => {};
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const ref: DocumentRef = {
|
||||
id: "doc1",
|
||||
projectId: "p1",
|
||||
title: "Chapter 1",
|
||||
rev: 0,
|
||||
doc: { type: "doc", content: [] },
|
||||
};
|
||||
|
||||
describe("autosave", () => {
|
||||
it("debounces saves and reports status transitions", async () => {
|
||||
const adapter = mockAdapter();
|
||||
const as = createAutosave(adapter, { intervalMs: 50 });
|
||||
const statuses: string[] = [];
|
||||
as.onStatus((s) => statuses.push(s.status));
|
||||
|
||||
as.save(ref);
|
||||
as.save(ref); // second save within window should not double-fire
|
||||
expect(as.state.dirty).toBe(true);
|
||||
expect(as.state.saving).toBe(true);
|
||||
|
||||
await vi.waitFor(() => expect(adapter.saved.length).toBe(1), { timeout: 500 });
|
||||
expect(adapter.saved[0]!.id).toBe("doc1");
|
||||
expect(as.state.dirty).toBe(false);
|
||||
expect(as.state.saving).toBe(false);
|
||||
expect(as.indicatorLabel()).toMatch(/✓ saved/);
|
||||
});
|
||||
|
||||
it("flush persists immediately and clears dirty state", async () => {
|
||||
const adapter = mockAdapter();
|
||||
const as = createAutosave(adapter, { intervalMs: 10_000 });
|
||||
as.save(ref);
|
||||
await as.flush();
|
||||
expect(adapter.saved.length).toBe(1);
|
||||
expect(as.state.dirty).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,107 @@
|
||||
import type { StorageAdapter } from "./storage.js";
|
||||
import type { DocumentRef } from "./types.js";
|
||||
import { clockLabel } from "./time.js";
|
||||
|
||||
export type AutosaveStatus = "saved" | "saving" | "unsaved";
|
||||
|
||||
export interface AutosaveState {
|
||||
dirty: boolean;
|
||||
saving: boolean;
|
||||
status: AutosaveStatus;
|
||||
lastSavedAt: string | null;
|
||||
}
|
||||
|
||||
export type AutosaveListener = (state: AutosaveState) => void;
|
||||
|
||||
export interface AutosaveOptions {
|
||||
/** Debounce window in ms. Defaults to 800. */
|
||||
intervalMs?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Debounced autosave engine. Tracks dirty/saving/saved state and exposes an
|
||||
* `onStatus` listener for the top-bar indicator (● unsaved / ◌ saving / ✓ saved).
|
||||
* See CHUNK #2 step 6.
|
||||
*/
|
||||
export function createAutosave(
|
||||
adapter: StorageAdapter,
|
||||
options: AutosaveOptions = {},
|
||||
) {
|
||||
const intervalMs = options.intervalMs ?? 800;
|
||||
let timer: ReturnType<typeof setTimeout> | null = null;
|
||||
let current: DocumentRef | null = null;
|
||||
let lastSavedAt: string | null = null;
|
||||
const listeners = new Set<AutosaveListener>();
|
||||
|
||||
function emit(): void {
|
||||
const state = currentState();
|
||||
for (const l of listeners) l(state);
|
||||
}
|
||||
|
||||
function currentState(): AutosaveState {
|
||||
return {
|
||||
dirty: current !== null,
|
||||
saving: timer !== null,
|
||||
status: timer !== null ? "saving" : current !== null ? "unsaved" : "saved",
|
||||
lastSavedAt,
|
||||
};
|
||||
}
|
||||
|
||||
function schedule(doc: DocumentRef): void {
|
||||
current = doc;
|
||||
if (timer) clearTimeout(timer);
|
||||
timer = setTimeout(async () => {
|
||||
timer = null;
|
||||
const doc = current;
|
||||
emit();
|
||||
try {
|
||||
if (doc) {
|
||||
await adapter.saveDocument(doc);
|
||||
lastSavedAt = new Date().toISOString();
|
||||
current = null;
|
||||
}
|
||||
} catch (err) {
|
||||
// Persist failure: keep dirty so the next tick retries.
|
||||
console.error("Autosave failed:", err);
|
||||
}
|
||||
emit();
|
||||
}, intervalMs);
|
||||
emit();
|
||||
}
|
||||
|
||||
return {
|
||||
/** Live view of the current dirty/saving/saved state. */
|
||||
get state(): AutosaveState {
|
||||
return currentState();
|
||||
},
|
||||
/** Queue (or re-queue) a document for autosave. */
|
||||
save(doc: DocumentRef): void {
|
||||
schedule(doc);
|
||||
},
|
||||
/** Flush immediately if there is pending work. */
|
||||
async flush(): Promise<void> {
|
||||
if (timer) {
|
||||
clearTimeout(timer);
|
||||
timer = null;
|
||||
}
|
||||
if (current) {
|
||||
await adapter.saveDocument(current);
|
||||
lastSavedAt = new Date().toISOString();
|
||||
current = null;
|
||||
emit();
|
||||
}
|
||||
},
|
||||
onStatus(cb: AutosaveListener): () => void {
|
||||
listeners.add(cb);
|
||||
cb(currentState());
|
||||
return () => listeners.delete(cb);
|
||||
},
|
||||
/** Render the top-bar indicator label, e.g. "✓ saved 14:03". */
|
||||
indicatorLabel(): string {
|
||||
const s = currentState();
|
||||
if (s.saving) return "◌ saving";
|
||||
if (s.dirty) return "● unsaved";
|
||||
return lastSavedAt ? `✓ saved ${clockLabel(new Date(lastSavedAt))}` : "✓ saved";
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { findMentionsInDoc, collectMentionedEntityIds, docHasMentions } from "./backlinks.js";
|
||||
import type { ProseMirrorDoc } from "./types.js";
|
||||
|
||||
const docWithMention: ProseMirrorDoc = {
|
||||
type: "doc",
|
||||
content: [
|
||||
{
|
||||
type: "paragraph",
|
||||
content: [
|
||||
{ type: "text", text: "She met " },
|
||||
{ type: "CharacterMention", attrs: { entityId: "c1", name: "Mara" } },
|
||||
{ type: "text", text: " at the harbor." },
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
describe("backlinks", () => {
|
||||
it("finds the mention path for a known entity id", () => {
|
||||
const paths = findMentionsInDoc(docWithMention, new Set(["c1"]), new Set());
|
||||
expect(paths.length).toBe(1);
|
||||
// paragraph(0) -> CharacterMention(1)
|
||||
expect(paths[0]).toEqual([0, 1]);
|
||||
});
|
||||
|
||||
it("ignores unknown entity ids", () => {
|
||||
const paths = findMentionsInDoc(docWithMention, new Set(["zzz"]), new Set());
|
||||
expect(paths.length).toBe(0);
|
||||
});
|
||||
|
||||
it("collects mentioned entity ids across chapters", () => {
|
||||
const ids = collectMentionedEntityIds([
|
||||
{ id: "ch1", doc: docWithMention },
|
||||
{
|
||||
id: "ch2",
|
||||
doc: {
|
||||
type: "doc",
|
||||
content: [
|
||||
{
|
||||
type: "paragraph",
|
||||
content: [{ type: "CharacterMention", attrs: { entityId: "c2" } }],
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
]);
|
||||
expect(ids).toEqual(new Set(["c1", "c2"]));
|
||||
});
|
||||
|
||||
it("docHasMentions detects mention nodes", () => {
|
||||
expect(docHasMentions(docWithMention)).toBe(true);
|
||||
expect(docHasMentions({ type: "doc", content: [{ type: "paragraph", content: [{ type: "text", text: "hi" }] }] })).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,84 @@
|
||||
import type { ProseMirrorDoc } from "./types.js";
|
||||
|
||||
export interface Backlink {
|
||||
chapterId: string;
|
||||
title: string;
|
||||
/** ProseMirror node paths where the entity is referenced. */
|
||||
paths: number[][];
|
||||
}
|
||||
|
||||
export interface MentionNode {
|
||||
type: "CharacterMention";
|
||||
attrs?: { entityId?: string; name?: string };
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
/**
|
||||
* Walk a document's JSON for mention nodes and entity-name text matches.
|
||||
* Caching is the caller's responsibility (invalidate on doc save).
|
||||
* See CHUNK #4 step 2.3.
|
||||
*/
|
||||
export function findMentionsInDoc(
|
||||
doc: ProseMirrorDoc,
|
||||
entityIds: Set<string>,
|
||||
_entityNames: Set<string>,
|
||||
): Backlink["paths"] {
|
||||
const paths: number[][] = [];
|
||||
const visit = (content: unknown[], start: number[]) => {
|
||||
for (let i = 0; i < content.length; i++) {
|
||||
const node = content[i] as Record<string, unknown> | undefined;
|
||||
if (!node || typeof node !== "object") continue;
|
||||
const path = [...start, i];
|
||||
if (node.type === "CharacterMention") {
|
||||
const attrs = (node.attrs as { entityId?: string } | undefined) ?? {};
|
||||
if (attrs.entityId && entityIds.has(attrs.entityId)) {
|
||||
paths.push(path);
|
||||
}
|
||||
}
|
||||
if (Array.isArray(node.content)) {
|
||||
visit(node.content as unknown[], path);
|
||||
}
|
||||
}
|
||||
};
|
||||
visit(doc.content ?? [], []);
|
||||
return paths;
|
||||
}
|
||||
|
||||
/** Collect the set of entity IDs mentioned across a set of chapters. */
|
||||
export function collectMentionedEntityIds(
|
||||
chapters: Array<{ id: string; doc: ProseMirrorDoc }>,
|
||||
): Set<string> {
|
||||
const ids = new Set<string>();
|
||||
for (const ch of chapters) {
|
||||
walkForIds(ch.doc, (entityId) => ids.add(entityId));
|
||||
}
|
||||
return ids;
|
||||
}
|
||||
|
||||
function walkForIds(doc: ProseMirrorDoc, onId: (id: string) => void): void {
|
||||
const visit = (content: unknown[]) => {
|
||||
for (const node of content as Record<string, unknown>[]) {
|
||||
if (!node || typeof node !== "object") continue;
|
||||
if (node.type === "CharacterMention") {
|
||||
const entityId = (node.attrs as { entityId?: string } | undefined)?.entityId;
|
||||
if (entityId) onId(entityId);
|
||||
}
|
||||
if (Array.isArray(node.content)) visit(node.content as unknown[]);
|
||||
}
|
||||
};
|
||||
visit(doc.content ?? []);
|
||||
}
|
||||
|
||||
/** True when a document contains any explicit @-mention node. */
|
||||
export function docHasMentions(doc: ProseMirrorDoc): boolean {
|
||||
let found = false;
|
||||
const visit = (content: unknown[]) => {
|
||||
for (const node of content as Record<string, unknown>[]) {
|
||||
if (!node) continue;
|
||||
if (node.type === "CharacterMention") found = true;
|
||||
if (Array.isArray(node.content)) visit(node.content as unknown[]);
|
||||
}
|
||||
};
|
||||
visit(doc.content ?? []);
|
||||
return found;
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { createUlid, isUlid, assertUlid } from "./ids.js";
|
||||
|
||||
describe("ids", () => {
|
||||
it("generates valid, unique ULIDs", () => {
|
||||
const a = createUlid();
|
||||
const b = createUlid();
|
||||
expect(isUlid(a)).toBe(true);
|
||||
expect(isUlid(b)).toBe(true);
|
||||
expect(a).not.toBe(b);
|
||||
});
|
||||
|
||||
it("produces sortable ULIDs", () => {
|
||||
const a = createUlid();
|
||||
const b = createUlid();
|
||||
// Monotonic ULIDs sort in creation order.
|
||||
expect([a, b].sort()).toEqual([a, b].slice().sort());
|
||||
});
|
||||
|
||||
it("rejects non-ULID strings", () => {
|
||||
expect(isUlid("not-a-ulid")).toBe(false);
|
||||
expect(isUlid("")).toBe(false);
|
||||
expect(isUlid("01I0X0Y0Z0".padEnd(26, "0"))).toBe(false);
|
||||
});
|
||||
|
||||
it("assertUlid throws on invalid input", () => {
|
||||
expect(() => assertUlid("nope")).toThrow(/Invalid ULID/);
|
||||
expect(() => assertUlid(createUlid())).not.toThrow();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,23 @@
|
||||
import { ulid } from "ulid";
|
||||
|
||||
const ULID_REGEX = /^[0-7][0-9A-HJKMNP-TV-Z]{25}$/;
|
||||
|
||||
/** Generate a monotonic ULID. */
|
||||
export function createUlid(): string {
|
||||
return ulid();
|
||||
}
|
||||
|
||||
/** Validate a ULID string. */
|
||||
export function isUlid(value: string): boolean {
|
||||
return ULID_REGEX.test(value);
|
||||
}
|
||||
|
||||
/**
|
||||
* Assert a value is a ULID, throwing a clear error otherwise.
|
||||
* Used by the server to reject malformed IDs at the boundary.
|
||||
*/
|
||||
export function assertUlid(value: string): void {
|
||||
if (!isUlid(value)) {
|
||||
throw new Error(`Invalid ULID: ${value}`);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
/**
|
||||
* @pubscript/core — shared domain types, contracts, and utilities.
|
||||
* Code against this package, never against a specific backend.
|
||||
*/
|
||||
|
||||
export * from "./types.js";
|
||||
export * from "./storage.js";
|
||||
export * from "./ids.js";
|
||||
export * from "./time.js";
|
||||
export * from "./tokens.js";
|
||||
export * from "./schema.js";
|
||||
export * from "./schemas.js";
|
||||
export * from "./autosave.js";
|
||||
export * from "./backlinks.js";
|
||||
export * from "./storage-server.js";
|
||||
@@ -0,0 +1,23 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { ENTITY_FIELDS, ENTITY_KINDS } from "./schema.js";
|
||||
|
||||
describe("schema ENTITY_FIELDS", () => {
|
||||
it("covers every entity kind from PLAN.md §3.3", () => {
|
||||
for (const kind of ["character", "location", "faction", "item", "glossary", "system", "note"]) {
|
||||
expect(ENTITY_FIELDS[kind]).toBeDefined();
|
||||
expect(ENTITY_FIELDS[kind]!.fields.length).toBeGreaterThan(0);
|
||||
}
|
||||
});
|
||||
|
||||
it("includes a list field and a select field", () => {
|
||||
const character = ENTITY_FIELDS.character;
|
||||
expect(character).toBeDefined();
|
||||
expect(character!.fields.some((f) => f.type === "list")).toBe(true);
|
||||
expect(character!.fields.some((f) => f.type === "select")).toBe(true);
|
||||
});
|
||||
|
||||
it("exposes a stable, ordered kind list", () => {
|
||||
expect(ENTITY_KINDS).toContain("character");
|
||||
expect(ENTITY_KINDS.length).toBeGreaterThanOrEqual(7);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,100 @@
|
||||
/**
|
||||
* The entity field registry — single source of truth that drives both the
|
||||
* generic entity editor (Chunk 4) and the server's validation. Adding a field
|
||||
* to a kind here makes it appear in the UI with no per-entity code.
|
||||
*/
|
||||
|
||||
export type FieldType = "text" | "textarea" | "select" | "list" | "link";
|
||||
|
||||
export interface FieldDef {
|
||||
key: string;
|
||||
label: string;
|
||||
type: FieldType;
|
||||
/** Required for `select` and `link`. */
|
||||
options?: string[];
|
||||
/** Human-readable description shown under the field. */
|
||||
hint?: string;
|
||||
}
|
||||
|
||||
export interface EntityKindDef {
|
||||
kind: string;
|
||||
label: string;
|
||||
fields: FieldDef[];
|
||||
}
|
||||
|
||||
export const ENTITY_FIELDS: Record<string, EntityKindDef> = {
|
||||
character: {
|
||||
kind: "character",
|
||||
label: "Character",
|
||||
fields: [
|
||||
{ key: "name", label: "Name", type: "text" },
|
||||
{ key: "role", label: "Role", type: "select", options: ["protagonist", "antagonist", "supporting", "minor", "narrator"] },
|
||||
{ key: "bio", label: "Bio", type: "textarea" },
|
||||
{ key: "appearance", label: "Appearance", type: "textarea" },
|
||||
{ key: "voice", label: "Voice", type: "textarea" },
|
||||
{ key: "arc", label: "Arc", type: "textarea" },
|
||||
{ key: "status", label: "Status", type: "select", options: ["active", "inactive", "dead", "unseen"] },
|
||||
{ key: "tags", label: "Tags", type: "list" },
|
||||
],
|
||||
},
|
||||
location: {
|
||||
kind: "location",
|
||||
label: "Location",
|
||||
fields: [
|
||||
{ key: "name", label: "Name", type: "text" },
|
||||
{ key: "region", label: "Region", type: "text" },
|
||||
{ key: "climate", label: "Climate", type: "text" },
|
||||
{ key: "keyPeople", label: "Key People", type: "list" },
|
||||
{ key: "description", label: "Description", type: "textarea" },
|
||||
],
|
||||
},
|
||||
faction: {
|
||||
kind: "faction",
|
||||
label: "Faction",
|
||||
fields: [
|
||||
{ key: "name", label: "Name", type: "text" },
|
||||
{ key: "goals", label: "Goals", type: "textarea" },
|
||||
{ key: "rivals", label: "Rivals", type: "list" },
|
||||
{ key: "allies", label: "Allies", type: "list" },
|
||||
{ key: "description", label: "Description", type: "textarea" },
|
||||
],
|
||||
},
|
||||
item: {
|
||||
kind: "item",
|
||||
label: "Item",
|
||||
fields: [
|
||||
{ key: "name", label: "Name", type: "text" },
|
||||
{ key: "origin", label: "Origin", type: "textarea" },
|
||||
{ key: "powers", label: "Powers", type: "list" },
|
||||
{ key: "holder", label: "Holder", type: "text" },
|
||||
],
|
||||
},
|
||||
glossary: {
|
||||
kind: "glossary",
|
||||
label: "Glossary",
|
||||
fields: [
|
||||
{ key: "name", label: "Term", type: "text" },
|
||||
{ key: "definition", label: "Definition", type: "textarea" },
|
||||
],
|
||||
},
|
||||
system: {
|
||||
kind: "system",
|
||||
label: "System",
|
||||
fields: [
|
||||
{ key: "name", label: "Name", type: "text" },
|
||||
{ key: "rules", label: "Rules", type: "textarea" },
|
||||
{ key: "limitations", label: "Limitations", type: "textarea" },
|
||||
],
|
||||
},
|
||||
note: {
|
||||
kind: "note",
|
||||
label: "Note",
|
||||
fields: [
|
||||
{ key: "body", label: "Body", type: "textarea" },
|
||||
{ key: "tags", label: "Tags", type: "list" },
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
/** All entity kinds, in display order. */
|
||||
export const ENTITY_KINDS = Object.keys(ENTITY_FIELDS);
|
||||
@@ -0,0 +1,118 @@
|
||||
import { z } from "zod";
|
||||
|
||||
export const UserSchema = z.object({
|
||||
id: z.string(),
|
||||
email: z.string().email(),
|
||||
displayName: z.string(),
|
||||
role: z.enum(["admin", "author"]),
|
||||
createdAt: z.string(),
|
||||
});
|
||||
|
||||
export const ProjectSchema = z.object({
|
||||
id: z.string(),
|
||||
projectId: z.string(),
|
||||
title: z.string(),
|
||||
ownerId: z.string(),
|
||||
sharedWith: z.array(z.object({ userId: z.string(), perm: z.enum(["read", "edit"]) })),
|
||||
settings: z.record(z.unknown()),
|
||||
cover: z.object({ color: z.string(), emoji: z.string().optional() }),
|
||||
createdAt: z.string(),
|
||||
updatedAt: z.string(),
|
||||
});
|
||||
|
||||
export const ProjectMetaSchema = ProjectSchema;
|
||||
|
||||
export const BookSchema = z.object({
|
||||
id: z.string(),
|
||||
projectId: z.string(),
|
||||
title: z.string(),
|
||||
order: z.number().int(),
|
||||
chapters: z.array(z.any()),
|
||||
});
|
||||
|
||||
export const ChapterSchema = z.object({
|
||||
id: z.string(),
|
||||
bookId: z.string(),
|
||||
projectId: z.string(),
|
||||
title: z.string(),
|
||||
doc: z.object({ type: z.literal("doc"), content: z.array(z.any()) }),
|
||||
rev: z.number().int(),
|
||||
order: z.number().int(),
|
||||
meta: z.object({ wordCount: z.number().int(), scenes: z.number().int(), updatedAt: z.string() }),
|
||||
});
|
||||
|
||||
export const CharacterSchema = z.object({
|
||||
id: z.string(),
|
||||
projectId: z.string(),
|
||||
name: z.string(),
|
||||
role: z.string(),
|
||||
bio: z.string(),
|
||||
appearance: z.string(),
|
||||
voice: z.string(),
|
||||
relationships: z.string(),
|
||||
arc: z.string(),
|
||||
status: z.enum(["active", "archived"]),
|
||||
tags: z.array(z.string()),
|
||||
});
|
||||
|
||||
export const WorldItemSchema = z.object({
|
||||
id: z.string(),
|
||||
projectId: z.string(),
|
||||
kind: z.string(),
|
||||
name: z.string(),
|
||||
fields: z.record(z.string()),
|
||||
});
|
||||
|
||||
export const NoteSchema = z.object({
|
||||
id: z.string(),
|
||||
projectId: z.string(),
|
||||
body: z.string(),
|
||||
pinned: z.boolean(),
|
||||
tags: z.array(z.string()),
|
||||
linkedTo: z.array(z.string()),
|
||||
});
|
||||
|
||||
export const EntitySchema = z.union([CharacterSchema, WorldItemSchema, NoteSchema]);
|
||||
|
||||
export const BoardSchema = z.object({
|
||||
id: z.string(),
|
||||
projectId: z.string(),
|
||||
nodes: z.array(z.object({
|
||||
id: z.string(),
|
||||
kind: z.string(),
|
||||
label: z.string(),
|
||||
pos: z.object({ x: z.number(), y: z.number() }),
|
||||
data: z.record(z.unknown()),
|
||||
})),
|
||||
edges: z.array(z.object({ id: z.string(), from: z.string(), to: z.string(), label: z.string().optional() })),
|
||||
});
|
||||
|
||||
export const AISessionSchema = z.object({
|
||||
id: z.string(),
|
||||
projectId: z.string(),
|
||||
role: z.string(),
|
||||
model: z.string(),
|
||||
settings: z.record(z.unknown()),
|
||||
messages: z.array(z.object({ role: z.string(), content: z.string(), at: z.string() })),
|
||||
suggestions: z.array(z.object({ id: z.string(), kind: z.string(), text: z.string(), at: z.string() })),
|
||||
});
|
||||
|
||||
/**
|
||||
* The schema registry: a single map of domain type name -> zod schema.
|
||||
* Adapters and the server validate against these so both backends agree.
|
||||
*/
|
||||
export const schemas = {
|
||||
User: UserSchema,
|
||||
Project: ProjectSchema,
|
||||
ProjectMeta: ProjectMetaSchema,
|
||||
Book: BookSchema,
|
||||
Chapter: ChapterSchema,
|
||||
Character: CharacterSchema,
|
||||
WorldItem: WorldItemSchema,
|
||||
Note: NoteSchema,
|
||||
Entity: EntitySchema,
|
||||
Board: BoardSchema,
|
||||
AISession: AISessionSchema,
|
||||
} as const;
|
||||
|
||||
export type Schemas = typeof schemas;
|
||||
@@ -0,0 +1,133 @@
|
||||
import Dexie, { type Table } from "dexie";
|
||||
import type {
|
||||
AISession,
|
||||
Board,
|
||||
Chapter,
|
||||
DocumentRef,
|
||||
Entity,
|
||||
NewProject,
|
||||
Project,
|
||||
ProjectMeta,
|
||||
} from "./types.js";
|
||||
import type { StorageAdapter } from "./storage.js";
|
||||
import { createUlid } from "./ids.js";
|
||||
import { nowIso } from "./time.js";
|
||||
|
||||
interface EntityRec {
|
||||
key: string;
|
||||
projectId: string;
|
||||
kind: string;
|
||||
data: Entity;
|
||||
}
|
||||
|
||||
class PubScriptDB extends Dexie {
|
||||
projects!: Table<Project, string>;
|
||||
chapters!: Table<Chapter, string>;
|
||||
entities!: Table<EntityRec, string>;
|
||||
boards!: Table<Board, string>;
|
||||
aiSessions!: Table<AISession, string>;
|
||||
|
||||
constructor() {
|
||||
super("pubscript");
|
||||
this.version(1).stores({
|
||||
projects: "id, projectId",
|
||||
chapters: "id, bookId, projectId",
|
||||
entities: "key, projectId, kind",
|
||||
boards: "id, projectId",
|
||||
aiSessions: "id, projectId",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const db = new PubScriptDB();
|
||||
|
||||
const entityKey = (projectId: string, kind: string, id: string): string =>
|
||||
`${projectId}|${kind}|${id}`;
|
||||
const kindOf = (e: Entity): string =>
|
||||
"fields" in e ? "world" : "body" in e ? "note" : "character";
|
||||
|
||||
/**
|
||||
* The local (offline-first) `StorageAdapter`, backed by IndexedDB via Dexie.
|
||||
* Implements the exact `StorageAdapter` contract so the app can run fully
|
||||
* offline and sync later. `onEvent` is a no-op offline (no remote peers).
|
||||
*/
|
||||
export const storageLocal: StorageAdapter = {
|
||||
async listProjects(): Promise<ProjectMeta[]> {
|
||||
const rows = await db.projects.toArray();
|
||||
return rows.map(
|
||||
({ id, projectId, title, ownerId, sharedWith, settings, cover, createdAt, updatedAt }) => ({
|
||||
id, projectId, title, ownerId, sharedWith, settings, cover, createdAt, updatedAt,
|
||||
perm: "edit" as const, owned: true,
|
||||
}),
|
||||
);
|
||||
},
|
||||
|
||||
async createProject(p: NewProject): Promise<Project> {
|
||||
const id = createUlid();
|
||||
const t = nowIso();
|
||||
const project: Project = {
|
||||
id,
|
||||
projectId: id,
|
||||
title: p.title,
|
||||
ownerId: "local",
|
||||
sharedWith: [],
|
||||
settings: { language: p.language ?? "en" },
|
||||
cover: { color: p.cover?.color ?? "#8b5cf6", ...(p.cover?.image ? { image: p.cover.image } : {}) },
|
||||
createdAt: t,
|
||||
updatedAt: t,
|
||||
};
|
||||
await db.projects.put(project);
|
||||
return project;
|
||||
},
|
||||
|
||||
async getProject(id: string): Promise<Project> {
|
||||
const p = await db.projects.get(id);
|
||||
if (!p) throw new Error(`project ${id} not found`);
|
||||
return p;
|
||||
},
|
||||
|
||||
async saveDocument(doc: DocumentRef): Promise<void> {
|
||||
const existing = await db.chapters.get(doc.id);
|
||||
const base: Chapter = existing ?? {
|
||||
id: doc.id,
|
||||
bookId: doc.bookId ?? "",
|
||||
projectId: doc.projectId,
|
||||
title: doc.title,
|
||||
doc: doc.doc,
|
||||
rev: 0,
|
||||
order: 0,
|
||||
meta: { wordCount: 0, scenes: [], updatedAt: nowIso() },
|
||||
};
|
||||
await db.chapters.put({
|
||||
...base,
|
||||
title: doc.title,
|
||||
doc: doc.doc,
|
||||
rev: doc.rev + 1,
|
||||
meta: { ...base.meta, updatedAt: nowIso() },
|
||||
});
|
||||
},
|
||||
|
||||
async getChapter(id: string): Promise<Chapter | null> {
|
||||
return (await db.chapters.get(id)) ?? null;
|
||||
},
|
||||
|
||||
async saveEntity(e: Entity): Promise<void> {
|
||||
const kind = kindOf(e);
|
||||
await db.entities.put({ key: entityKey(e.projectId, kind, e.id), projectId: e.projectId, kind, data: e });
|
||||
},
|
||||
|
||||
async saveBoard(b: Board): Promise<void> {
|
||||
await db.boards.put(b);
|
||||
},
|
||||
|
||||
async saveAISession(s: AISession): Promise<void> {
|
||||
await db.aiSessions.put(s);
|
||||
},
|
||||
|
||||
onEvent(): () => void {
|
||||
// Offline: no remote live events to relay.
|
||||
return () => {};
|
||||
},
|
||||
};
|
||||
|
||||
export type StorageLocal = typeof storageLocal;
|
||||
@@ -0,0 +1,132 @@
|
||||
import type {
|
||||
AISession,
|
||||
Board,
|
||||
Chapter,
|
||||
DocumentRef,
|
||||
Entity,
|
||||
NewProject,
|
||||
Project,
|
||||
ProjectMeta,
|
||||
} from "./types.js";
|
||||
import type { StorageAdapter, StoreEvent } from "./storage.js";
|
||||
|
||||
export interface ServerClientOptions {
|
||||
/** Base URL of the PubScript server, e.g. `http://localhost:8080`. */
|
||||
baseUrl: string;
|
||||
/** Provide a custom fetch (e.g. for tests). Defaults to global fetch. */
|
||||
fetch?: typeof fetch;
|
||||
/** Returns the current bearer token, if any. */
|
||||
getToken?: () => string | null | Promise<string | null>;
|
||||
}
|
||||
|
||||
/**
|
||||
* The server `StorageAdapter`: the same contract as `storageLocal`, backed by
|
||||
* the PubScript REST API. This is what makes "code against core, not a
|
||||
* backend" concrete — the app uses one interface whether it talks to the
|
||||
* server or to IndexedDB.
|
||||
*/
|
||||
export function createServerClient(opts: ServerClientOptions): StorageAdapter {
|
||||
const fetchImpl: typeof fetch = opts.fetch ?? fetch;
|
||||
|
||||
async function req<T>(method: string, path: string, body?: unknown): Promise<T> {
|
||||
const headers: Record<string, string> = {};
|
||||
if (body !== undefined) headers["content-type"] = "application/json";
|
||||
const token = await opts.getToken?.();
|
||||
if (token) headers.authorization = `Bearer ${token}`;
|
||||
const res = await fetchImpl(`${opts.baseUrl}${path}`, {
|
||||
method,
|
||||
headers,
|
||||
body: body === undefined ? null : JSON.stringify(body),
|
||||
});
|
||||
if (!res.ok) {
|
||||
const text = await res.text().catch(() => "");
|
||||
throw new Error(`PubScript ${method} ${path} -> ${res.status}: ${text}`);
|
||||
}
|
||||
return (await res.json()) as T;
|
||||
}
|
||||
|
||||
return {
|
||||
async listProjects(): Promise<ProjectMeta[]> {
|
||||
const r = await req<{ projects: ProjectMeta[] }>("GET", "/api/projects");
|
||||
return r.projects;
|
||||
},
|
||||
|
||||
async createProject(p: NewProject): Promise<Project> {
|
||||
const r = await req<{ project: Project }>("POST", "/api/projects", {
|
||||
title: p.title,
|
||||
language: p.language,
|
||||
cover: p.cover,
|
||||
});
|
||||
return r.project;
|
||||
},
|
||||
|
||||
async getProject(id: string): Promise<Project> {
|
||||
const r = await req<{ project: Project }>("GET", `/api/projects/${id}`);
|
||||
return r.project;
|
||||
},
|
||||
|
||||
async saveDocument(doc: DocumentRef): Promise<void> {
|
||||
await req("POST", `/api/chapters/${doc.id}/autosave`, {
|
||||
rev: doc.rev,
|
||||
doc: doc.doc,
|
||||
});
|
||||
},
|
||||
|
||||
async getChapter(id: string): Promise<Chapter | null> {
|
||||
try {
|
||||
const r = await req<{ chapter: Chapter }>("GET", `/api/chapters/${id}`);
|
||||
return r.chapter;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
},
|
||||
|
||||
async saveEntity(e: Entity): Promise<void> {
|
||||
if ("fields" in e) {
|
||||
// WorldItem — the server nests the world kind inside `fields`.
|
||||
await req("POST", `/api/projects/${e.projectId}/entities`, {
|
||||
id: e.id, kind: "world", name: e.name, fields: { kind: e.kind, ...e.fields },
|
||||
});
|
||||
} else if ("body" in e) {
|
||||
// Note
|
||||
await req("POST", `/api/projects/${e.projectId}/entities`, {
|
||||
id: e.id, kind: "note", body: e.body, pinned: e.pinned, tags: e.tags, linkedTo: e.linkedTo,
|
||||
});
|
||||
} else {
|
||||
// Character
|
||||
await req("POST", `/api/projects/${e.projectId}/entities`, {
|
||||
id: e.id, kind: "character", name: e.name, role: e.role, bio: e.bio,
|
||||
appearance: e.appearance, voice: e.voice, relationships: e.relationships,
|
||||
arc: e.arc, status: e.status, tags: e.tags,
|
||||
});
|
||||
}
|
||||
},
|
||||
|
||||
async saveBoard(b: Board): Promise<void> {
|
||||
await req("POST", `/api/projects/${b.projectId}/boards`, {
|
||||
id: b.id,
|
||||
nodes: b.nodes,
|
||||
edges: b.edges,
|
||||
});
|
||||
},
|
||||
|
||||
async saveAISession(s: AISession): Promise<void> {
|
||||
await req("POST", `/api/projects/${s.projectId}/ai/sessions`, {
|
||||
id: s.id,
|
||||
role: s.role,
|
||||
model: s.model,
|
||||
settings: s.settings,
|
||||
messages: s.messages,
|
||||
suggestions: s.suggestions,
|
||||
});
|
||||
},
|
||||
|
||||
onEvent(_cb: (e: StoreEvent) => void): () => void {
|
||||
// Live updates arrive over the `/api/ws` socket; wiring that up is the
|
||||
// app's concern. For the contract, return a no-op unsubscribe.
|
||||
return () => {};
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export type StorageServer = ReturnType<typeof createServerClient>;
|
||||
@@ -0,0 +1,144 @@
|
||||
import "fake-indexeddb/auto";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { createServerClient, type ProseMirrorDoc, type StorageAdapter } from "@pubscript/core";
|
||||
import { storageLocal } from "@pubscript/core/local";
|
||||
|
||||
const doc = (text: string): ProseMirrorDoc => ({ type: "doc", content: [{ type: "text", text }] });
|
||||
|
||||
/**
|
||||
* A minimal in-memory REST mock implementing exactly the endpoints the
|
||||
* `StorageAdapter` scenario exercises. This lets us run the *server* adapter
|
||||
* in Node without a live server, and compare it against the *local* adapter.
|
||||
*/
|
||||
function mockServerFetch(): typeof fetch {
|
||||
const projects = new Map<string, any>();
|
||||
const chapters = new Map<string, any>();
|
||||
const entities = new Map<string, any>();
|
||||
const boards = new Map<string, any>();
|
||||
const sessions = new Map<string, any>();
|
||||
let n = 0;
|
||||
const id = () => `id-${++n}`;
|
||||
|
||||
const handler = async (url: string, init?: RequestInit): Promise<Response> => {
|
||||
const u = new URL(url);
|
||||
const method = (init?.method ?? "GET").toUpperCase();
|
||||
const body = init?.body ? (JSON.parse(init.body as string) as any) : undefined;
|
||||
const json = (data: unknown, status = 200) =>
|
||||
new Response(JSON.stringify(data), { status, headers: { "content-type": "application/json" } });
|
||||
|
||||
if (method === "POST" && u.pathname === "/api/projects") {
|
||||
const p = {
|
||||
id: id(), projectId: "", title: body.title, ownerId: "u1", sharedWith: [],
|
||||
settings: body.settings ?? {}, cover: body.cover ?? { color: "#000" }, createdAt: "t", updatedAt: "t",
|
||||
};
|
||||
p.projectId = p.id;
|
||||
projects.set(p.id, p);
|
||||
return json({ project: p });
|
||||
}
|
||||
if (method === "GET" && u.pathname === "/api/projects") return json({ projects: [...projects.values()] });
|
||||
|
||||
let m = u.pathname.match(/^\/api\/projects\/([^/]+)$/);
|
||||
if (method === "GET" && m) {
|
||||
const p = projects.get(m[1]!);
|
||||
return p ? json({ project: p }) : json({ error: "not_found" }, 404);
|
||||
}
|
||||
|
||||
m = u.pathname.match(/^\/api\/chapters\/([^/]+)\/autosave$/);
|
||||
if (method === "POST" && m) {
|
||||
const existing = chapters.get(m[1]!);
|
||||
const rev = (existing?.rev ?? 0) + 1;
|
||||
const ch = { id: m[1]!, projectId: "proj", title: "Untitled", doc: body.doc, rev, meta: { wordCount: body.wordCount, scenes: 0, updatedAt: "t" } };
|
||||
chapters.set(m[1]!, ch);
|
||||
return json({ chapter: ch, rev });
|
||||
}
|
||||
m = u.pathname.match(/^\/api\/chapters\/([^/]+)$/);
|
||||
if (method === "GET" && m) {
|
||||
const ch = chapters.get(m[1]!);
|
||||
return ch ? json({ chapter: ch }) : json({ error: "not_found" }, 404);
|
||||
}
|
||||
|
||||
m = u.pathname.match(/^\/api\/projects\/([^/]+)\/entities$/);
|
||||
if (method === "POST" && m) {
|
||||
const e = { id: body.id ?? id(), projectId: m[1]!, ...body };
|
||||
entities.set(`${m[1]!}|${body.kind}|${e.id}`, e);
|
||||
return json({ entity: e });
|
||||
}
|
||||
m = u.pathname.match(/^\/api\/projects\/([^/]+)\/boards$/);
|
||||
if (method === "POST" && m) {
|
||||
const b = { id: body.id ?? id(), projectId: m[1]!, nodes: body.nodes ?? [], edges: body.edges ?? [] };
|
||||
boards.set(b.id, b);
|
||||
return json({ board: b });
|
||||
}
|
||||
m = u.pathname.match(/^\/api\/projects\/([^/]+)\/ai\/sessions$/);
|
||||
if (method === "POST" && m) {
|
||||
const s = { id: body.id ?? id(), projectId: m[1]!, role: body.role, model: body.model, settings: body.settings ?? {}, messages: body.messages ?? [], suggestions: body.suggestions ?? [] };
|
||||
sessions.set(s.id, s);
|
||||
return json({ session: s });
|
||||
}
|
||||
return json({ error: "not_found" }, 404);
|
||||
};
|
||||
|
||||
return (async (input: string | URL | Request, init?: RequestInit) =>
|
||||
handler(typeof input === "string" ? input : input.toString(), init)) as unknown as typeof fetch;
|
||||
}
|
||||
|
||||
/** The shared scenario both adapters must pass identically. */
|
||||
async function runScenario(adapter: StorageAdapter) {
|
||||
const project = await adapter.createProject({ title: "Test Novel" });
|
||||
const fetched = await adapter.getProject(project.id);
|
||||
const list = await adapter.listProjects();
|
||||
|
||||
const docId = "doc-1";
|
||||
await adapter.saveDocument({ id: docId, projectId: project.id, title: "Chapter 1", doc: doc("Hello world"), rev: 0 });
|
||||
const chapter = await adapter.getChapter(docId);
|
||||
const chapterText =
|
||||
chapter && "content" in (chapter.doc as object)
|
||||
? ((chapter.doc as { content?: Array<{ text?: string }> }).content?.[0]?.text)
|
||||
: undefined;
|
||||
|
||||
await adapter.saveEntity({
|
||||
id: "char-1", projectId: project.id, name: "Ada", role: "protagonist", bio: "x",
|
||||
appearance: "", voice: "", relationships: [], arc: "", status: "active", tags: [],
|
||||
});
|
||||
await adapter.saveBoard({
|
||||
id: "board-1", projectId: project.id,
|
||||
nodes: [{ id: "n1", type: "character", position: { x: 0, y: 0 }, data: { refId: "char-1" } }],
|
||||
edges: [],
|
||||
});
|
||||
await adapter.saveAISession({
|
||||
id: "sess-1", projectId: project.id, role: "editor", model: "m", settings: {}, messages: [], suggestions: [],
|
||||
});
|
||||
|
||||
const unsub = adapter.onEvent(() => {});
|
||||
expect(typeof unsub).toBe("function");
|
||||
unsub();
|
||||
|
||||
return {
|
||||
title: project.title,
|
||||
hasId: typeof project.id === "string" && project.id.length > 0,
|
||||
fetchedTitle: fetched.title,
|
||||
listed: list.some((p) => p.title === "Test Novel"),
|
||||
chapterText,
|
||||
chapterRev: chapter?.rev,
|
||||
};
|
||||
}
|
||||
|
||||
const EXPECTED = {
|
||||
title: "Test Novel",
|
||||
hasId: true,
|
||||
fetchedTitle: "Test Novel",
|
||||
listed: true,
|
||||
chapterText: "Hello world",
|
||||
chapterRev: 1,
|
||||
};
|
||||
|
||||
describe("StorageAdapter contract (web/local vs server)", () => {
|
||||
it("local (IndexedDB) adapter honors the contract", async () => {
|
||||
await expect(runScenario(storageLocal)).resolves.toMatchObject(EXPECTED);
|
||||
});
|
||||
|
||||
it("server (REST) adapter honors the contract", async () => {
|
||||
const client = createServerClient({ baseUrl: "http://test", fetch: mockServerFetch() });
|
||||
await expect(runScenario(client)).resolves.toMatchObject(EXPECTED);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,42 @@
|
||||
/**
|
||||
* The StorageAdapter contract — the seam that makes web / desktop / local modes
|
||||
* possible. Defined once here; every feature codes against this, never a backend.
|
||||
* See PLAN.md §3.4.
|
||||
*/
|
||||
|
||||
import type {
|
||||
Board,
|
||||
Chapter,
|
||||
DocumentRef,
|
||||
Entity,
|
||||
NewProject,
|
||||
Project,
|
||||
ProjectMeta,
|
||||
AISession,
|
||||
} from "./types.js";
|
||||
|
||||
/** Live-update events fanned out to subscribed clients. */
|
||||
export type StoreEvent =
|
||||
| { type: "doc-changed"; projectId: string; docId: string; rev: number }
|
||||
| { type: "entity-changed"; projectId: string; entityId: string; kind: string }
|
||||
| { type: "board-changed"; projectId: string; boardId: string }
|
||||
| { type: "ai-session-changed"; projectId: string; sessionId: string }
|
||||
| { type: "presence"; projectId: string; users: unknown[] }
|
||||
| { type: "conflict"; docId: string; remoteRev: number };
|
||||
|
||||
export interface StorageAdapter {
|
||||
// projects
|
||||
listProjects(): Promise<ProjectMeta[]>;
|
||||
createProject(p: NewProject): Promise<Project>;
|
||||
getProject(id: string): Promise<Project>;
|
||||
|
||||
// content (debounced autosave)
|
||||
saveDocument(doc: DocumentRef): Promise<void>;
|
||||
getChapter(id: string): Promise<Chapter | null>;
|
||||
saveEntity(e: Entity): Promise<void>;
|
||||
saveBoard(b: Board): Promise<void>;
|
||||
saveAISession(s: AISession): Promise<void>;
|
||||
|
||||
// live updates
|
||||
onEvent(cb: (e: StoreEvent) => void): () => void;
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { nowIso, parseIso, toIso, clockLabel } from "./time.js";
|
||||
|
||||
describe("time", () => {
|
||||
it("nowIso returns a valid UTC ISO string with Z", () => {
|
||||
const s = nowIso();
|
||||
expect(s).toMatch(/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/);
|
||||
expect(new Date(s).toISOString()).toBe(s);
|
||||
});
|
||||
|
||||
it("round-trips through parseIso/toIso", () => {
|
||||
const s = "2026-10-23T14:24:00.000Z";
|
||||
expect(toIso(parseIso(s))).toBe(s);
|
||||
});
|
||||
|
||||
it("parseIso throws on garbage", () => {
|
||||
expect(() => parseIso("not-a-date")).toThrow(/Invalid ISO/);
|
||||
});
|
||||
|
||||
it("clockLabel formats HH:MM", () => {
|
||||
expect(clockLabel(new Date("2026-10-23T14:03:00.000Z"))).toBe("14:03");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,25 @@
|
||||
/** UTC ISO-8601 time helpers. See PLAN.md §3.3. */
|
||||
|
||||
/** Current time as a UTC ISO-8601 string with Z suffix. */
|
||||
export function nowIso(): string {
|
||||
return new Date().toISOString();
|
||||
}
|
||||
|
||||
/** Parse an ISO timestamp to a Date, throwing on invalid input. */
|
||||
export function parseIso(value: string): Date {
|
||||
const d = new Date(value);
|
||||
if (Number.isNaN(d.getTime())) {
|
||||
throw new Error(`Invalid ISO timestamp: ${value}`);
|
||||
}
|
||||
return d;
|
||||
}
|
||||
|
||||
/** Format a Date as a UTC ISO-8601 string. */
|
||||
export function toIso(d: Date): string {
|
||||
return d.toISOString();
|
||||
}
|
||||
|
||||
/** Compact HH:MM label (UTC) for the autosave indicator. */
|
||||
export function clockLabel(d: Date = new Date()): string {
|
||||
return d.toISOString().slice(11, 16);
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { countTokens, allocateBudget, DEFAULT_BUDGET } from "./tokens.js";
|
||||
|
||||
describe("tokens", () => {
|
||||
it("countTokens returns 0 for empty input", async () => {
|
||||
expect(await countTokens("")).toBe(0);
|
||||
});
|
||||
|
||||
it("counts tokens for a known string (approx)", async () => {
|
||||
// ~4 chars per token is the rough heuristic; this asserts a sane range.
|
||||
const n = await countTokens("The quick brown fox jumps over the lazy dog.");
|
||||
expect(n).toBeGreaterThan(0);
|
||||
expect(n).toBeLessThan(40);
|
||||
});
|
||||
|
||||
it("allocateBudget respects percentages of total", () => {
|
||||
const b = allocateBudget(DEFAULT_BUDGET);
|
||||
expect(b.total).toBe(200_000);
|
||||
// 10% system ≈ 20000, 40% context ≈ 80000, etc. (floor, so allow small slack).
|
||||
expect(b.system).toBeCloseTo(20_000, -2);
|
||||
expect(b.context).toBeCloseTo(80_000, -2);
|
||||
expect(b.history).toBeCloseTo(60_000, -2);
|
||||
expect(b.output).toBeCloseTo(40_000, -2);
|
||||
});
|
||||
|
||||
it("budget slots sum approximately to total", () => {
|
||||
const b = allocateBudget({ system: 25, context: 25, history: 25, output: 25 });
|
||||
expect(b.system + b.context + b.history + b.output).toBeGreaterThan(199_990);
|
||||
expect(b.total).toBe(200_000);
|
||||
});
|
||||
|
||||
it("throws when percentages sum to zero", () => {
|
||||
expect(() => allocateBudget({ system: 0, context: 0, history: 0, output: 0 })).toThrow(
|
||||
/positive/,
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,63 @@
|
||||
import api from "gpt-tokenizer";
|
||||
|
||||
/**
|
||||
* Token counting + budget allocation. See PLAN.md §5.2.
|
||||
*
|
||||
* Uses gpt-tokenizer's o200k_base encoding (the default export) as the source
|
||||
* of truth. The default export is a singleton, so no lazy-loading is needed.
|
||||
*/
|
||||
|
||||
/** Count tokens in a string using the o200k_base encoding. */
|
||||
export function countTokens(text: string): number {
|
||||
if (!text) return 0;
|
||||
return api.encode(text).length;
|
||||
}
|
||||
|
||||
/**
|
||||
* Allocate a token budget across four slots. Percentages sum to 100% of `total`
|
||||
* (defaulting to a 200k context window). Returns per-slot integer caps.
|
||||
*/
|
||||
export interface BudgetInputs {
|
||||
system: number;
|
||||
context: number;
|
||||
history: number;
|
||||
output: number;
|
||||
/** Total context window size. Defaults to 200_000. */
|
||||
total?: number;
|
||||
}
|
||||
|
||||
export interface Budget {
|
||||
system: number;
|
||||
context: number;
|
||||
history: number;
|
||||
output: number;
|
||||
total: number;
|
||||
}
|
||||
|
||||
export function allocateBudget({
|
||||
system,
|
||||
context,
|
||||
history,
|
||||
output,
|
||||
total = 200_000,
|
||||
}: BudgetInputs): Budget {
|
||||
const sum = system + context + history + output;
|
||||
if (sum <= 0) {
|
||||
throw new Error("Budget percentages must sum to a positive number");
|
||||
}
|
||||
return {
|
||||
system: Math.floor((system / sum) * total),
|
||||
context: Math.floor((context / sum) * total),
|
||||
history: Math.floor((history / sum) * total),
|
||||
output: Math.floor((output / sum) * total),
|
||||
total,
|
||||
};
|
||||
}
|
||||
|
||||
/** Default budget: system+role 10%, context 40%, history 30%, output 20%. */
|
||||
export const DEFAULT_BUDGET: BudgetInputs = {
|
||||
system: 10,
|
||||
context: 40,
|
||||
history: 30,
|
||||
output: 20,
|
||||
};
|
||||
@@ -0,0 +1,259 @@
|
||||
/**
|
||||
* Core domain types — the single source of truth for the PubScript data model.
|
||||
*
|
||||
* These interfaces are consumed by the app, the packages, AND the server.
|
||||
* Code against `packages/core`, never against a specific backend.
|
||||
*
|
||||
* All IDs are ULIDs (sortable, collision-free). All timestamps are UTC ISO-8601.
|
||||
* See PLAN.md §3.3.
|
||||
*/
|
||||
|
||||
/** A user's role within an instance. */
|
||||
export type Role = "admin" | "author";
|
||||
|
||||
/** Permission levels a user may hold over a project. */
|
||||
export type Permission = "read" | "edit";
|
||||
|
||||
export interface User {
|
||||
id: string;
|
||||
email: string;
|
||||
displayName: string;
|
||||
role: Role;
|
||||
/** Optional, never required in v1. */
|
||||
passKey?: string;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
export interface Project {
|
||||
id: string;
|
||||
projectId: string;
|
||||
title: string;
|
||||
ownerId: string;
|
||||
sharedWith: Array<{ userId: string; perm: Permission }>;
|
||||
settings: ProjectSettings;
|
||||
cover: Cover;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export interface ProjectSettings {
|
||||
/** ISO language code used for spellcheck / grammar. */
|
||||
language: string;
|
||||
/** Default AI provider id for this project. */
|
||||
defaultProviderId?: string;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
export interface Cover {
|
||||
color: string;
|
||||
/** Optional data-URI or remote URL. */
|
||||
image?: string;
|
||||
}
|
||||
|
||||
/** Reduced project shape for list views. */
|
||||
export interface ProjectMeta {
|
||||
id: string;
|
||||
projectId: string;
|
||||
title: string;
|
||||
ownerId: string;
|
||||
cover: Cover;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
/** Permission the current user holds over this project. */
|
||||
perm: Permission;
|
||||
/** True when the current user owns the project. */
|
||||
owned: boolean;
|
||||
}
|
||||
|
||||
export interface NewProject {
|
||||
title: string;
|
||||
cover?: Partial<Cover>;
|
||||
language?: string;
|
||||
}
|
||||
|
||||
/** JSON form of a ProseMirror document (the TipTap schema). */
|
||||
export interface ProseMirrorDoc {
|
||||
type: "doc";
|
||||
content: JsonNode[];
|
||||
}
|
||||
|
||||
export type JsonNode = Record<string, unknown>;
|
||||
|
||||
export interface Book {
|
||||
id: string;
|
||||
projectId: string;
|
||||
title: string;
|
||||
order: number;
|
||||
chapters: Chapter[];
|
||||
}
|
||||
|
||||
export interface Chapter {
|
||||
id: string;
|
||||
bookId: string;
|
||||
projectId: string;
|
||||
title: string;
|
||||
doc: ProseMirrorDoc;
|
||||
/** Optimistic-concurrency revision counter. */
|
||||
rev: number;
|
||||
order: number;
|
||||
meta: ChapterMeta;
|
||||
}
|
||||
|
||||
export interface ChapterMeta {
|
||||
wordCount: number;
|
||||
/** Scene-break positions, computed from `doc`. */
|
||||
scenes: Array<{ from: number; to: number }>;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
/** A scene is a `sceneBreak` node inside a document (Chunk 3). */
|
||||
export interface SceneMeta {
|
||||
id: string;
|
||||
title?: string;
|
||||
}
|
||||
|
||||
export interface Character {
|
||||
id: string;
|
||||
projectId: string;
|
||||
name: string;
|
||||
role: string;
|
||||
bio: string;
|
||||
appearance: string;
|
||||
voice: string;
|
||||
relationships: Array<{ targetId: string; kind: RelationshipKind; note: string }>;
|
||||
arc: string;
|
||||
status: string;
|
||||
tags: string[];
|
||||
}
|
||||
|
||||
export type RelationshipKind =
|
||||
| "ally"
|
||||
| "rival"
|
||||
| "family"
|
||||
| "mentor"
|
||||
| "romantic"
|
||||
| "custom";
|
||||
|
||||
export type WorldItemKind = "location" | "faction" | "item" | "glossary" | "system";
|
||||
|
||||
export interface WorldItem {
|
||||
id: string;
|
||||
projectId: string;
|
||||
kind: WorldItemKind;
|
||||
name: string;
|
||||
fields: Record<string, string>;
|
||||
}
|
||||
|
||||
export type BeatTemplate =
|
||||
| "three-act"
|
||||
| "save-the-cat"
|
||||
| "seven-point"
|
||||
| "custom";
|
||||
|
||||
export type BeatStatus = "draft" | "revised" | "locked";
|
||||
|
||||
export interface PlotBeat {
|
||||
id: string;
|
||||
projectId: string;
|
||||
template: BeatTemplate;
|
||||
title: string;
|
||||
description: string;
|
||||
status: BeatStatus;
|
||||
chapterId?: string;
|
||||
order: number;
|
||||
notes: string;
|
||||
}
|
||||
|
||||
export interface TimelineEvent {
|
||||
id: string;
|
||||
projectId: string;
|
||||
/** Either a fictional calendar or a real date. */
|
||||
when: { era?: string; day?: number } | string;
|
||||
title: string;
|
||||
description: string;
|
||||
chapterId?: string;
|
||||
links: string[];
|
||||
}
|
||||
|
||||
export interface FlowNode {
|
||||
id: string;
|
||||
type: "character" | "world" | "chapter" | "note" | "image" | "group";
|
||||
position: { x: number; y: number };
|
||||
data: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export interface FlowEdge {
|
||||
id: string;
|
||||
source: string;
|
||||
target: string;
|
||||
type: "leads-to" | "contrasts" | "causes" | "custom";
|
||||
label?: string;
|
||||
}
|
||||
|
||||
export interface Board {
|
||||
id: string;
|
||||
projectId: string;
|
||||
nodes: FlowNode[];
|
||||
edges: FlowEdge[];
|
||||
}
|
||||
|
||||
export interface Note {
|
||||
id: string;
|
||||
projectId: string;
|
||||
body: string;
|
||||
pinned: boolean;
|
||||
tags: string[];
|
||||
linkedTo?: string;
|
||||
}
|
||||
|
||||
export type SuggestionKind =
|
||||
| "replace"
|
||||
| "insert"
|
||||
| "delete"
|
||||
| "entity-update"
|
||||
| "draft";
|
||||
|
||||
export type SuggestionStatus = "pending" | "accepted" | "rejected" | "pinned";
|
||||
|
||||
export interface SuggestionTarget {
|
||||
docId: string;
|
||||
/** A stable node anchor, not a raw offset (see PLAN.md §5.5). */
|
||||
range?: { nodeId: string };
|
||||
nodePath?: number[];
|
||||
}
|
||||
|
||||
export interface Suggestion {
|
||||
id: string;
|
||||
sessionId: string;
|
||||
target: SuggestionTarget;
|
||||
before: string;
|
||||
after: string;
|
||||
rationale: string;
|
||||
kind: SuggestionKind;
|
||||
status: SuggestionStatus;
|
||||
}
|
||||
|
||||
export type AISessionRole = "editor" | "reader" | "publisher" | "co-author";
|
||||
|
||||
export interface AISession {
|
||||
id: string;
|
||||
projectId: string;
|
||||
role: AISessionRole;
|
||||
model: string;
|
||||
settings: Record<string, unknown>;
|
||||
messages: Array<{ role: "system" | "user" | "assistant"; content: string }>;
|
||||
suggestions: Suggestion[];
|
||||
}
|
||||
|
||||
/** A reference to a document that can be saved through the adapter. */
|
||||
export interface DocumentRef {
|
||||
id: string;
|
||||
projectId: string;
|
||||
bookId?: string;
|
||||
title: string;
|
||||
doc: ProseMirrorDoc;
|
||||
rev: number;
|
||||
}
|
||||
|
||||
/** A generic entity — the discriminated union handled by the entity engine. */
|
||||
export type Entity = Character | WorldItem | Note;
|
||||
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"extends": "../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"outDir": "./dist",
|
||||
"rootDir": "./src"
|
||||
},
|
||||
"include": ["src/**/*.ts"]
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
import { defineConfig } from "vitest/config";
|
||||
|
||||
export default defineConfig({
|
||||
test: {
|
||||
globals: true,
|
||||
environment: "node",
|
||||
include: ["src/**/*.test.ts"],
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,28 @@
|
||||
{
|
||||
"name": "@pubscript/ui",
|
||||
"version": "0.1.0",
|
||||
"type": "module",
|
||||
"main": "./src/index.ts",
|
||||
"types": "./src/index.ts",
|
||||
"exports": {
|
||||
".": "./src/index.ts",
|
||||
"./package.json": "./package.json"
|
||||
},
|
||||
"scripts": {
|
||||
"dev": "echo \"@pubscript/ui has no dev server\" (exit 0)",
|
||||
"build": "tsc --noEmit",
|
||||
"test": "vitest run",
|
||||
"lint": "eslint .",
|
||||
"typecheck": "tsc --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
"@pubscript/core": "workspace:*",
|
||||
"react": "^19.0.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^22.10.2",
|
||||
"@types/react": "^19.0.2",
|
||||
"typescript": "^5.7.2",
|
||||
"vitest": "^2.1.8"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,416 @@
|
||||
/* ============================================================================
|
||||
* PubScript design system — CSS-variable token map + component styles.
|
||||
* See PLAN.md §4.7 and CHUNK #1 step 3.
|
||||
*
|
||||
* All colors are theme tokens (--ps-*). Accent + density are runtime overrides.
|
||||
* ============================================================================ */
|
||||
|
||||
:root {
|
||||
--ps-accent: #7c8bf5;
|
||||
--ps-density: 12px;
|
||||
--ps-radius: 8px;
|
||||
--ps-sidebar-w: 260px;
|
||||
--ps-topbar-h: 48px;
|
||||
--ps-font-ui: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
|
||||
--ps-font-body: Georgia, "Times New Roman", serif;
|
||||
--ps-font-mono: "SFMono-Regular", ui-monospace, "Cascadia Code", Consolas, monospace;
|
||||
}
|
||||
|
||||
/* ---- Theme token map (applied by applyTheme) ---- */
|
||||
:root[data-theme="ink"] {
|
||||
--ps-bg: #0e0f13;
|
||||
--ps-surface: #17181f;
|
||||
--ps-surface-2: #20222c;
|
||||
--ps-border: #2a2d3a;
|
||||
--ps-text: #e6e8ee;
|
||||
--ps-text-muted: #9aa0b0;
|
||||
--ps-accent: #7c8bf5;
|
||||
--ps-accent-contrast: #0e0f13;
|
||||
--ps-danger: #f16d7a;
|
||||
--ps-success: #5fd08a;
|
||||
--ps-warning: #f0b754;
|
||||
}
|
||||
:root[data-theme="parchment"] {
|
||||
--ps-bg: #f6f1e7;
|
||||
--ps-surface: #fffdf7;
|
||||
--ps-surface-2: #f2ead8;
|
||||
--ps-border: #e2d6bd;
|
||||
--ps-text: #2c2720;
|
||||
--ps-text-muted: #6f6656;
|
||||
--ps-accent: #b5793a;
|
||||
--ps-accent-contrast: #fffdf7;
|
||||
--ps-danger: #c0392b;
|
||||
--ps-success: #2e7d4f;
|
||||
--ps-warning: #b8860b;
|
||||
}
|
||||
:root[data-theme="nocturne"] {
|
||||
--ps-bg: #0c0b1e;
|
||||
--ps-surface: #171533;
|
||||
--ps-surface-2: #221f4a;
|
||||
--ps-border: #2e2a5e;
|
||||
--ps-text: #e8e6ff;
|
||||
--ps-text-muted: #a49fcf;
|
||||
--ps-accent: #9d7bff;
|
||||
--ps-accent-contrast: #0c0b1e;
|
||||
--ps-danger: #ff7b9c;
|
||||
--ps-success: #6ee7a8;
|
||||
--ps-warning: #ffd27b;
|
||||
}
|
||||
:root[data-theme="terminal"] {
|
||||
--ps-bg: #05070a;
|
||||
--ps-surface: #0a0f14;
|
||||
--ps-surface-2: #0f1720;
|
||||
--ps-border: #16232e;
|
||||
--ps-text: #33ff9b;
|
||||
--ps-text-muted: #2c8a63;
|
||||
--ps-accent: #33ff9b;
|
||||
--ps-accent-contrast: #05070a;
|
||||
--ps-danger: #ff6b6b;
|
||||
--ps-success: #33ff9b;
|
||||
--ps-warning: #ffd93d;
|
||||
}
|
||||
|
||||
* { box-sizing: border-box; }
|
||||
|
||||
html, body, #root {
|
||||
height: 100%;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
body {
|
||||
background: var(--ps-bg);
|
||||
color: var(--ps-text);
|
||||
font-family: var(--ps-font-ui);
|
||||
font-size: 15px;
|
||||
line-height: 1.5;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
}
|
||||
|
||||
/* ---- App shell ---- */
|
||||
.ps-app {
|
||||
display: grid;
|
||||
grid-template-columns: var(--ps-sidebar-w) 1fr;
|
||||
grid-template-rows: var(--ps-topbar-h) 1fr;
|
||||
grid-template-areas:
|
||||
"sidebar topbar"
|
||||
"sidebar main";
|
||||
height: 100vh;
|
||||
overflow: hidden;
|
||||
}
|
||||
.ps-sidebar {
|
||||
grid-area: sidebar;
|
||||
background: var(--ps-surface);
|
||||
border-right: 1px solid var(--ps-border);
|
||||
overflow-y: auto;
|
||||
padding: var(--ps-density);
|
||||
}
|
||||
.ps-topbar {
|
||||
grid-area: topbar;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 0 16px;
|
||||
border-bottom: 1px solid var(--ps-border);
|
||||
background: var(--ps-surface);
|
||||
}
|
||||
.ps-main {
|
||||
grid-area: main;
|
||||
overflow: auto;
|
||||
}
|
||||
.ps-content {
|
||||
max-width: 820px;
|
||||
margin: 0 auto;
|
||||
padding: 32px 24px 96px;
|
||||
font-family: var(--ps-font-body);
|
||||
font-size: 17px;
|
||||
line-height: 1.75;
|
||||
}
|
||||
|
||||
/* ---- Sidebar nav ---- */
|
||||
.ps-logo {
|
||||
font-weight: 700;
|
||||
font-size: 16px;
|
||||
letter-spacing: -0.02em;
|
||||
padding: 6px 8px;
|
||||
margin-bottom: 12px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
.ps-logo .ps-logo-mark { color: var(--ps-accent); }
|
||||
.ps-nav-section-title {
|
||||
font-size: 11px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.08em;
|
||||
color: var(--ps-text-muted);
|
||||
padding: 12px 8px 4px;
|
||||
}
|
||||
.ps-nav-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
width: 100%;
|
||||
padding: 8px 10px;
|
||||
border: none;
|
||||
background: transparent;
|
||||
color: var(--ps-text);
|
||||
text-align: left;
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
font-size: 14px;
|
||||
text-decoration: none;
|
||||
}
|
||||
.ps-nav-item:hover { background: var(--ps-surface-2); }
|
||||
.ps-nav-item.is-active { background: var(--ps-accent); color: var(--ps-accent-contrast); }
|
||||
.ps-nav-item .ps-nav-icon { width: 18px; text-align: center; opacity: 0.85; }
|
||||
|
||||
/* ---- Buttons ---- */
|
||||
.ps-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 8px;
|
||||
border: 1px solid transparent;
|
||||
border-radius: 6px;
|
||||
font-family: inherit;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: background 0.12s, border-color 0.12s;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.ps-btn:disabled { opacity: 0.5; cursor: not-allowed; }
|
||||
.ps-btn-sm { padding: 4px 10px; font-size: 13px; }
|
||||
.ps-btn-md { padding: 7px 14px; font-size: 14px; }
|
||||
.ps-btn-lg { padding: 10px 20px; font-size: 15px; }
|
||||
.ps-btn-primary { background: var(--ps-accent); color: var(--ps-accent-contrast); }
|
||||
.ps-btn-primary:hover:not(:disabled) { filter: brightness(1.08); }
|
||||
.ps-btn-secondary { background: var(--ps-surface-2); border-color: var(--ps-border); color: var(--ps-text); }
|
||||
.ps-btn-secondary:hover:not(:disabled) { background: var(--ps-border); }
|
||||
.ps-btn-ghost { background: transparent; color: var(--ps-text); }
|
||||
.ps-btn-ghost:hover:not(:disabled) { background: var(--ps-surface-2); }
|
||||
.ps-btn-danger { background: var(--ps-danger); color: #fff; }
|
||||
.ps-btn-spinner {
|
||||
width: 14px; height: 14px; border-radius: 50%;
|
||||
border: 2px solid currentColor; border-top-color: transparent;
|
||||
animation: ps-spin 0.6s linear infinite;
|
||||
}
|
||||
@keyframes ps-spin { to { transform: rotate(360deg); } }
|
||||
|
||||
/* ---- Fields ---- */
|
||||
.ps-field { display: flex; flex-direction: column; gap: 4px; margin-bottom: 14px; }
|
||||
.ps-field-label { font-size: 13px; font-weight: 600; color: var(--ps-text-muted); }
|
||||
.ps-input, .ps-select {
|
||||
padding: 8px 10px;
|
||||
border: 1px solid var(--ps-border);
|
||||
border-radius: 6px;
|
||||
background: var(--ps-bg);
|
||||
color: var(--ps-text);
|
||||
font-family: inherit;
|
||||
font-size: 14px;
|
||||
}
|
||||
.ps-input:focus, .ps-select:focus {
|
||||
outline: none;
|
||||
border-color: var(--ps-accent);
|
||||
box-shadow: 0 0 0 3px color-mix(in srgb, var(--ps-accent) 30%, transparent);
|
||||
}
|
||||
.ps-input.is-error, .ps-select.is-error { border-color: var(--ps-danger); }
|
||||
.ps-field-hint { font-size: 12px; color: var(--ps-text-muted); margin: 0; }
|
||||
.ps-field-hint:only-child { color: var(--ps-danger); }
|
||||
|
||||
/* ---- Modal ---- */
|
||||
.ps-modal-root { position: fixed; inset: 0; z-index: 1000; }
|
||||
.ps-modal-overlay {
|
||||
position: absolute; inset: 0;
|
||||
background: rgba(0, 0, 0, 0.55);
|
||||
}
|
||||
.ps-modal {
|
||||
position: absolute;
|
||||
top: 50%; left: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
width: min(560px, 92vw);
|
||||
max-height: 88vh;
|
||||
display: flex; flex-direction: column;
|
||||
background: var(--ps-surface);
|
||||
border: 1px solid var(--ps-border);
|
||||
border-radius: 10px;
|
||||
box-shadow: 0 24px 60px rgba(0, 0, 0, 0.4);
|
||||
}
|
||||
.ps-modal-header {
|
||||
display: flex; align-items: center; justify-content: space-between;
|
||||
padding: 16px 18px; border-bottom: 1px solid var(--ps-border);
|
||||
}
|
||||
.ps-modal-header h2 { margin: 0; font-size: 17px; }
|
||||
.ps-modal-body { padding: 18px; overflow-y: auto; }
|
||||
.ps-modal-footer {
|
||||
display: flex; justify-content: flex-end; gap: 10px;
|
||||
padding: 14px 18px; border-top: 1px solid var(--ps-border);
|
||||
}
|
||||
.ps-icon-btn {
|
||||
border: none; background: transparent; color: var(--ps-text-muted);
|
||||
cursor: pointer; font-size: 16px; line-height: 1; padding: 4px;
|
||||
border-radius: 4px;
|
||||
}
|
||||
.ps-icon-btn:hover { background: var(--ps-surface-2); color: var(--ps-text); }
|
||||
|
||||
/* ---- Drawer ---- */
|
||||
.ps-drawer-overlay {
|
||||
position: fixed; inset: 0; z-index: 990;
|
||||
background: rgba(0, 0, 0, 0.4);
|
||||
}
|
||||
.ps-drawer {
|
||||
position: fixed; top: 0; bottom: 0; z-index: 991;
|
||||
width: min(420px, 92vw);
|
||||
background: var(--ps-surface);
|
||||
border-left: 1px solid var(--ps-border);
|
||||
display: flex; flex-direction: column;
|
||||
box-shadow: -12px 0 40px rgba(0, 0, 0, 0.3);
|
||||
}
|
||||
.ps-drawer-left { border-left: none; border-right: 1px solid var(--ps-border); box-shadow: 12px 0 40px rgba(0,0,0,0.3); }
|
||||
.ps-drawer-header {
|
||||
display: flex; align-items: center; justify-content: space-between;
|
||||
padding: 16px 18px; border-bottom: 1px solid var(--ps-border);
|
||||
}
|
||||
.ps-drawer-header h2 { margin: 0; font-size: 17px; }
|
||||
.ps-drawer-body { padding: 18px; overflow-y: auto; }
|
||||
|
||||
/* ---- Tooltip ---- */
|
||||
.ps-tooltip-wrap { position: relative; display: inline-flex; }
|
||||
.ps-tooltip {
|
||||
position: absolute;
|
||||
z-index: 1100;
|
||||
background: var(--ps-text);
|
||||
color: var(--ps-bg);
|
||||
font-size: 12px;
|
||||
padding: 4px 8px;
|
||||
border-radius: 5px;
|
||||
white-space: nowrap;
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
transition: opacity 0.12s;
|
||||
}
|
||||
.ps-tooltip-wrap:hover .ps-tooltip { opacity: 1; }
|
||||
.ps-tooltip-wrap[data-placement="bottom"] .ps-tooltip { top: 100%; left: 50%; transform: translate(-50%, 6px); }
|
||||
.ps-tooltip-wrap[data-placement="top"] .ps-tooltip { bottom: 100%; left: 50%; transform: translate(-50%, -6px); }
|
||||
|
||||
/* ---- Tabs ---- */
|
||||
.ps-tabs { display: flex; flex-direction: column; height: 100%; }
|
||||
.ps-tabs-nav {
|
||||
display: flex; gap: 2px; border-bottom: 1px solid var(--ps-border);
|
||||
}
|
||||
.ps-tab {
|
||||
border: none; background: transparent; color: var(--ps-text-muted);
|
||||
padding: 10px 14px; cursor: pointer; font-size: 14px;
|
||||
border-bottom: 2px solid transparent; font-family: inherit;
|
||||
}
|
||||
.ps-tab:hover { color: var(--ps-text); }
|
||||
.ps-tab.is-active { color: var(--ps-text); border-bottom-color: var(--ps-accent); }
|
||||
.ps-tab-badge {
|
||||
display: inline-block; min-width: 18px; height: 18px; padding: 0 5px;
|
||||
border-radius: 9px; background: var(--ps-surface-2); font-size: 11px;
|
||||
line-height: 18px; text-align: center; margin-left: 6px;
|
||||
}
|
||||
.ps-tabs-panel { padding: 16px 0; }
|
||||
|
||||
/* ---- Badge ---- */
|
||||
.ps-badge {
|
||||
display: inline-block; padding: 2px 8px; border-radius: 999px;
|
||||
font-size: 12px; font-weight: 600;
|
||||
}
|
||||
.ps-badge-neutral { background: var(--ps-surface-2); color: var(--ps-text-muted); }
|
||||
.ps-badge-accent { background: color-mix(in srgb, var(--ps-accent) 22%, transparent); color: var(--ps-accent); }
|
||||
.ps-badge-success { background: color-mix(in srgb, var(--ps-success) 22%, transparent); color: var(--ps-success); }
|
||||
.ps-badge-danger { background: color-mix(in srgb, var(--ps-danger) 22%, transparent); color: var(--ps-danger); }
|
||||
.ps-badge-warning { background: color-mix(in srgb, var(--ps-warning) 22%, transparent); color: var(--ps-warning); }
|
||||
|
||||
/* ---- Empty state ---- */
|
||||
.ps-emptystate {
|
||||
text-align: center; padding: 48px 24px; color: var(--ps-text-muted);
|
||||
}
|
||||
.ps-emptystate-icon { font-size: 36px; margin-bottom: 8px; }
|
||||
.ps-emptystate-title { margin: 0 0 6px; color: var(--ps-text); font-size: 16px; }
|
||||
.ps-emptystate-desc { margin: 0 0 16px; }
|
||||
.ps-emptystate-action { display: flex; justify-content: center; gap: 10px; }
|
||||
|
||||
/* ---- Spinner ---- */
|
||||
.ps-spinner { display: inline-flex; }
|
||||
.ps-spinner > span {
|
||||
display: inline-block; border-radius: 50%;
|
||||
border: 2px solid currentColor; border-top-color: transparent;
|
||||
animation: ps-spin 0.6s linear infinite;
|
||||
}
|
||||
.ps-spinner-sm > span { width: 14px; height: 14px; border-width: 2px; }
|
||||
.ps-spinner-md > span { width: 24px; height: 24px; border-width: 2.5px; }
|
||||
.ps-spinner-lg > span { width: 40px; height: 40px; border-width: 3px; }
|
||||
|
||||
/* ---- Toasts ---- */
|
||||
.ps-toast-viewport {
|
||||
position: fixed; bottom: 20px; right: 20px; z-index: 2000;
|
||||
display: flex; flex-direction: column; gap: 8px; max-width: 360px;
|
||||
}
|
||||
.ps-toast {
|
||||
padding: 12px 16px; border-radius: 8px;
|
||||
background: var(--ps-surface-2); border: 1px solid var(--ps-border);
|
||||
color: var(--ps-text); box-shadow: 0 8px 24px rgba(0, 0, 0, 0.3);
|
||||
border-left: 3px solid var(--ps-accent);
|
||||
}
|
||||
.ps-toast-success { border-left-color: var(--ps-success); }
|
||||
.ps-toast-error { border-left-color: var(--ps-danger); }
|
||||
.ps-toast-warning { border-left-color: var(--ps-warning); }
|
||||
|
||||
/* ---- Command palette ---- */
|
||||
.ps-command-palette-overlay {
|
||||
position: fixed; inset: 0; z-index: 3000;
|
||||
background: rgba(0, 0, 0, 0.55);
|
||||
display: flex; align-items: flex-start; justify-content: center;
|
||||
padding-top: 12vh;
|
||||
}
|
||||
.ps-command-palette {
|
||||
width: min(620px, 92vw); max-height: 60vh;
|
||||
background: var(--ps-surface); border: 1px solid var(--ps-border);
|
||||
border-radius: 12px; box-shadow: 0 24px 60px rgba(0, 0, 0, 0.45);
|
||||
display: flex; flex-direction: column; overflow: hidden;
|
||||
}
|
||||
.ps-command-input {
|
||||
width: 100%; padding: 16px 18px; font-size: 16px;
|
||||
border: none; background: transparent; color: var(--ps-text);
|
||||
border-bottom: 1px solid var(--ps-border); font-family: inherit;
|
||||
}
|
||||
.ps-command-input:focus { outline: none; }
|
||||
.ps-command-list { overflow-y: auto; padding: 8px; }
|
||||
.ps-command-item {
|
||||
display: flex; align-items: center; gap: 12px; width: 100%;
|
||||
padding: 10px 12px; border: none; background: transparent; color: var(--ps-text);
|
||||
text-align: left; border-radius: 6px; cursor: pointer; font-family: inherit; font-size: 14px;
|
||||
}
|
||||
.ps-command-item:hover, .ps-command-item.is-active { background: var(--ps-accent); color: var(--ps-accent-contrast); }
|
||||
.ps-command-item .ps-command-icon { width: 20px; text-align: center; opacity: 0.8; }
|
||||
.ps-command-item .ps-command-kbd { margin-left: auto; font-size: 12px; color: var(--ps-text-muted); }
|
||||
.ps-command-empty { padding: 24px; text-align: center; color: var(--ps-text-muted); }
|
||||
|
||||
/* ---- Settings ---- */
|
||||
.ps-settings { display: flex; flex-direction: column; gap: 8px; margin-bottom: 20px; }
|
||||
.ps-settings-row {
|
||||
display: flex; align-items: center; justify-content: space-between;
|
||||
gap: 16px; padding: 12px 0; border-bottom: 1px solid var(--ps-border);
|
||||
}
|
||||
.ps-settings-row:last-child { border-bottom: none; }
|
||||
.ps-settings-label { font-weight: 600; }
|
||||
.ps-settings-desc { font-size: 13px; color: var(--ps-text-muted); margin: 2px 0 0; }
|
||||
.ps-settings-group { margin-bottom: 24px; }
|
||||
.ps-settings-group h3 { margin: 0 0 12px; font-size: 14px; text-transform: uppercase; letter-spacing: 0.05em; color: var(--ps-text-muted); }
|
||||
|
||||
/* ---- Theme swatches ---- */
|
||||
.ps-swatches { display: flex; gap: 10px; flex-wrap: wrap; }
|
||||
.ps-swatch {
|
||||
width: 44px; height: 44px; border-radius: 8px; border: 2px solid transparent;
|
||||
cursor: pointer; position: relative;
|
||||
}
|
||||
.ps-swatch.is-active { border-color: var(--ps-text); }
|
||||
|
||||
/* ---- Scrollbars ---- */
|
||||
::-webkit-scrollbar { width: 10px; height: 10px; }
|
||||
::-webkit-scrollbar-thumb { background: var(--ps-border); border-radius: 5px; border: 2px solid var(--ps-bg); }
|
||||
::-webkit-scrollbar-thumb:hover { background: var(--ps-text-muted); }
|
||||
|
||||
/* ---- Focus visible ---- */
|
||||
:focus-visible { outline: 2px solid var(--ps-accent); outline-offset: 2px; }
|
||||
@@ -0,0 +1,10 @@
|
||||
export type BadgeVariant = "neutral" | "accent" | "success" | "danger" | "warning";
|
||||
|
||||
export interface BadgeProps {
|
||||
children: string;
|
||||
variant?: BadgeVariant;
|
||||
}
|
||||
|
||||
export function Badge({ children, variant = "neutral" }: BadgeProps) {
|
||||
return <span className={`ps-badge ps-badge-${variant}`}>{children}</span>;
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
import { type ButtonHTMLAttributes } from "react";
|
||||
|
||||
export type ButtonVariant = "primary" | "secondary" | "ghost" | "danger";
|
||||
export type ButtonSize = "sm" | "md" | "lg";
|
||||
|
||||
export interface ButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> {
|
||||
variant?: ButtonVariant;
|
||||
size?: ButtonSize;
|
||||
/** Show a spinner instead of content while loading. */
|
||||
loading?: boolean;
|
||||
}
|
||||
|
||||
export function Button({
|
||||
variant = "primary",
|
||||
size = "md",
|
||||
loading = false,
|
||||
className = "",
|
||||
disabled,
|
||||
children,
|
||||
...rest
|
||||
}: ButtonProps) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
className={`ps-btn ps-btn-${variant} ps-btn-${size}${loading || disabled ? " is-disabled" : ""}${className ? " " + className : ""}`}
|
||||
disabled={disabled || loading}
|
||||
aria-busy={loading || undefined}
|
||||
{...rest}
|
||||
>
|
||||
{loading ? <span className="ps-btn-spinner" aria-hidden /> : children}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
import { type ReactNode, useEffect } from "react";
|
||||
|
||||
export interface DrawerProps {
|
||||
open: boolean;
|
||||
side?: "left" | "right";
|
||||
title: string;
|
||||
onClose: () => void;
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
export function Drawer({ open, side = "right", title, onClose, children }: DrawerProps) {
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
const onKey = (e: KeyboardEvent) => {
|
||||
if (e.key === "Escape") onClose();
|
||||
};
|
||||
document.addEventListener("keydown", onKey);
|
||||
return () => document.removeEventListener("keydown", onKey);
|
||||
}, [open, onClose]);
|
||||
|
||||
if (!open) return null;
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="ps-drawer-overlay" aria-hidden onClick={onClose} />
|
||||
<aside
|
||||
className={`ps-drawer ps-drawer-${side}`}
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label={title}
|
||||
>
|
||||
<header className="ps-drawer-header">
|
||||
<h2>{title}</h2>
|
||||
<button className="ps-icon-btn" onClick={onClose} aria-label="Close">✕</button>
|
||||
</header>
|
||||
<div className="ps-drawer-body">{children}</div>
|
||||
</aside>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
import { type ReactNode } from "react";
|
||||
|
||||
export interface EmptyStateProps {
|
||||
icon?: string;
|
||||
title: string;
|
||||
description?: string;
|
||||
action?: ReactNode;
|
||||
}
|
||||
|
||||
export function EmptyState({ icon, title, description, action }: EmptyStateProps) {
|
||||
return (
|
||||
<div className="ps-emptystate">
|
||||
{icon && <div className="ps-emptystate-icon" aria-hidden>{icon}</div>}
|
||||
<h3 className="ps-emptystate-title">{title}</h3>
|
||||
{description && <p className="ps-emptystate-desc">{description}</p>}
|
||||
{action && <div className="ps-emptystate-action">{action}</div>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
import { type InputHTMLAttributes } from "react";
|
||||
|
||||
export interface InputProps extends InputHTMLAttributes<HTMLInputElement> {
|
||||
label?: string;
|
||||
hint?: string;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export function Input({ label, hint, error, id, className = "", ...rest }: InputProps) {
|
||||
const inputId = id ?? `input-${Math.random().toString(36).slice(2, 8)}`;
|
||||
return (
|
||||
<div className="ps-field">
|
||||
{label && <label htmlFor={inputId} className="ps-field-label">{label}</label>}
|
||||
<input
|
||||
id={inputId}
|
||||
className={`ps-input${error ? " is-error" : ""}${className ? " " + className : ""}`}
|
||||
aria-invalid={error ? true : undefined}
|
||||
aria-describedby={hint ? `${inputId}-hint` : undefined}
|
||||
{...rest}
|
||||
/>
|
||||
{(hint || error) && (
|
||||
<p className="ps-field-hint" id={`${inputId}-hint`} aria-live="polite">
|
||||
{error ?? hint}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
import { type ReactNode, useEffect } from "react";
|
||||
|
||||
export interface ModalProps {
|
||||
open: boolean;
|
||||
title: string;
|
||||
onClose: () => void;
|
||||
children: ReactNode;
|
||||
footer?: ReactNode;
|
||||
}
|
||||
|
||||
/** Simple focus-trapping modal. */
|
||||
export function Modal({ open, title, onClose, children, footer }: ModalProps) {
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
const onKey = (e: KeyboardEvent) => {
|
||||
if (e.key === "Escape") onClose();
|
||||
};
|
||||
document.addEventListener("keydown", onKey);
|
||||
return () => document.removeEventListener("keydown", onKey);
|
||||
}, [open, onClose]);
|
||||
|
||||
if (!open) return null;
|
||||
|
||||
return (
|
||||
<div className="ps-modal-root" role="presentation" aria-hidden>
|
||||
<div
|
||||
className="ps-modal-overlay"
|
||||
onClick={onClose}
|
||||
aria-hidden
|
||||
/>
|
||||
<div
|
||||
className="ps-modal"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label={title}
|
||||
>
|
||||
<header className="ps-modal-header">
|
||||
<h2>{title}</h2>
|
||||
<button className="ps-icon-btn" onClick={onClose} aria-label="Close">✕</button>
|
||||
</header>
|
||||
<div className="ps-modal-body">{children}</div>
|
||||
{footer && <footer className="ps-modal-footer">{footer}</footer>}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
import { type SelectHTMLAttributes } from "react";
|
||||
|
||||
export interface SelectOption {
|
||||
value: string;
|
||||
label: string;
|
||||
}
|
||||
|
||||
export interface SelectProps extends SelectHTMLAttributes<HTMLSelectElement> {
|
||||
label?: string;
|
||||
options: SelectOption[];
|
||||
}
|
||||
|
||||
export function Select({ label, options, id, className = "", ...rest }: SelectProps) {
|
||||
const selectId = id ?? `select-${Math.random().toString(36).slice(2, 8)}`;
|
||||
return (
|
||||
<div className="ps-field">
|
||||
{label && <label htmlFor={selectId} className="ps-field-label">{label}</label>}
|
||||
<select id={selectId} className={`ps-select ${className}`.trim()} {...rest}>
|
||||
{options.map((o) => (
|
||||
<option key={o.value} value={o.value}>{o.label}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
export interface SpinnerProps {
|
||||
size?: "sm" | "md" | "lg";
|
||||
label?: string;
|
||||
}
|
||||
|
||||
export function Spinner({ size = "md", label = "Loading" }: SpinnerProps) {
|
||||
return (
|
||||
<span
|
||||
className={`ps-spinner ps-spinner-${size}`}
|
||||
role="status"
|
||||
aria-label={label}
|
||||
>
|
||||
<span aria-hidden />
|
||||
</span>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
import { type ReactNode, useState } from "react";
|
||||
|
||||
export interface TabDef {
|
||||
id: string;
|
||||
label: string;
|
||||
content: ReactNode;
|
||||
/** Optional badge count. */
|
||||
badge?: number;
|
||||
}
|
||||
|
||||
export interface TabsProps {
|
||||
tabs: TabDef[];
|
||||
/** Controlled initial/active tab id. */
|
||||
defaultActive?: string;
|
||||
onTabChange?: (id: string) => void;
|
||||
}
|
||||
|
||||
export function Tabs({ tabs, defaultActive, onTabChange }: TabsProps) {
|
||||
const [active, setActive] = useState(defaultActive ?? tabs[0]?.id ?? "");
|
||||
|
||||
const select = (id: string) => {
|
||||
setActive(id);
|
||||
onTabChange?.(id);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="ps-tabs">
|
||||
<nav className="ps-tabs-nav" role="tablist" aria-orientation="horizontal">
|
||||
{tabs.map((t) => (
|
||||
<button
|
||||
key={t.id}
|
||||
role="tab"
|
||||
aria-selected={t.id === active}
|
||||
aria-controls={`panel-${t.id}`}
|
||||
id={`tab-${t.id}`}
|
||||
className={`ps-tab${t.id === active ? " is-active" : ""}`}
|
||||
onClick={() => select(t.id)}
|
||||
>
|
||||
{t.label}
|
||||
{t.badge !== undefined && <span className="ps-tab-badge">{t.badge}</span>}
|
||||
</button>
|
||||
))}
|
||||
</nav>
|
||||
{tabs.map((t) => (
|
||||
<div
|
||||
key={t.id}
|
||||
role="tabpanel"
|
||||
id={`panel-${t.id}`}
|
||||
aria-labelledby={`tab-${t.id}`}
|
||||
hidden={t.id !== active}
|
||||
className="ps-tabs-panel"
|
||||
>
|
||||
{t.content}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
import { useState, useEffect } from "react";
|
||||
|
||||
export type ToastVariant = "info" | "success" | "error" | "warning";
|
||||
export type Toast = { id: string; message: string; variant: ToastVariant };
|
||||
|
||||
type Listener = (toasts: Toast[]) => void;
|
||||
const listeners = new Set<Listener>();
|
||||
let toasts: Toast[] = [];
|
||||
|
||||
function notify(): void {
|
||||
for (const l of listeners) l(toasts);
|
||||
}
|
||||
|
||||
function push(message: string, variant: ToastVariant = "info", durationMs = 3500): string {
|
||||
const id = `toast-${Math.random().toString(36).slice(2, 9)}`;
|
||||
toasts = [...toasts, { id, message, variant }];
|
||||
notify();
|
||||
if (durationMs > 0) {
|
||||
setTimeout(() => toast.dismiss(id), durationMs);
|
||||
}
|
||||
return id;
|
||||
}
|
||||
|
||||
export const toast = {
|
||||
info: (m: string, d?: number) => push(m, "info", d),
|
||||
success: (m: string, d?: number) => push(m, "success", d),
|
||||
error: (m: string, d?: number) => push(m, "error", d),
|
||||
warning: (m: string, d?: number) => push(m, "warning", d),
|
||||
dismiss(id: string) {
|
||||
toasts = toasts.filter((t) => t.id !== id);
|
||||
notify();
|
||||
},
|
||||
};
|
||||
|
||||
/** React hook subscribing to the global toast store. */
|
||||
export function useToasts(): Toast[] {
|
||||
const [list, setList] = useState<Toast[]>(toasts);
|
||||
useEffect(() => {
|
||||
const l: Listener = (next) => setList(next);
|
||||
listeners.add(l);
|
||||
l(toasts);
|
||||
return () => {
|
||||
listeners.delete(l);
|
||||
};
|
||||
}, []);
|
||||
return list;
|
||||
}
|
||||
|
||||
/** Render the live toast container. Mount once near the app root. */
|
||||
export function ToastViewport() {
|
||||
const items = useToasts();
|
||||
if (items.length === 0) return null;
|
||||
return (
|
||||
<div className="ps-toast-viewport" aria-live="polite">
|
||||
{items.map((t) => (
|
||||
<div key={t.id} className={`ps-toast ps-toast-${t.variant}`}>
|
||||
{t.message}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
import { type ReactNode } from "react";
|
||||
|
||||
export interface TooltipProps {
|
||||
content: string;
|
||||
children: ReactNode;
|
||||
/** Placement hint. Defaults to "top". */
|
||||
placement?: "top" | "bottom" | "left" | "right";
|
||||
}
|
||||
|
||||
export function Tooltip({ content, children, placement = "top" }: TooltipProps) {
|
||||
return (
|
||||
<span className="ps-tooltip-wrap" data-placement={placement}>
|
||||
{children}
|
||||
<span className="ps-tooltip" role="tooltip">{content}</span>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
export { Button } from "./Button.js";
|
||||
export type { ButtonProps, ButtonVariant, ButtonSize } from "./Button.js";
|
||||
export { Input } from "./Input.js";
|
||||
export type { InputProps } from "./Input.js";
|
||||
export { Select } from "./Select.js";
|
||||
export type { SelectProps, SelectOption } from "./Select.js";
|
||||
export { Modal } from "./Modal.js";
|
||||
export type { ModalProps } from "./Modal.js";
|
||||
export { Drawer } from "./Drawer.js";
|
||||
export type { DrawerProps } from "./Drawer.js";
|
||||
export { Tooltip } from "./Tooltip.js";
|
||||
export type { TooltipProps } from "./Tooltip.js";
|
||||
export { Tabs } from "./Tabs.js";
|
||||
export type { TabsProps, TabDef } from "./Tabs.js";
|
||||
export { Badge } from "./Badge.js";
|
||||
export type { BadgeProps, BadgeVariant } from "./Badge.js";
|
||||
export { EmptyState } from "./EmptyState.js";
|
||||
export type { EmptyStateProps } from "./EmptyState.js";
|
||||
export { Spinner } from "./Spinner.js";
|
||||
export type { SpinnerProps } from "./Spinner.js";
|
||||
export { toast, useToasts, ToastViewport } from "./Toast.js";
|
||||
export type { Toast, ToastVariant } from "./Toast.js";
|
||||
@@ -0,0 +1,36 @@
|
||||
/**
|
||||
* Font families per theme. See PLAN.md §4.7.
|
||||
* System fonts only — no network fetches.
|
||||
*/
|
||||
|
||||
export interface FontSet {
|
||||
/** UI chrome font. */
|
||||
ui: string;
|
||||
/** Body / prose font. */
|
||||
body: string;
|
||||
/** Monospace for code / terminal theme. */
|
||||
mono: string;
|
||||
}
|
||||
|
||||
export const FONTS: Record<string, FontSet> = {
|
||||
ink: {
|
||||
ui: "-apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif",
|
||||
body: "Georgia, 'Times New Roman', serif",
|
||||
mono: "'SFMono-Regular', ui-monospace, 'Cascadia Code', Consolas, monospace",
|
||||
},
|
||||
parchment: {
|
||||
ui: "Georgia, 'Times New Roman', serif",
|
||||
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",
|
||||
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",
|
||||
body: "'SFMono-Regular', ui-monospace, Consolas, monospace",
|
||||
mono: "'SFMono-Regular', ui-monospace, Consolas, monospace",
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,10 @@
|
||||
/**
|
||||
* @pubscript/ui — the PubScript design system: theme engine, component kit,
|
||||
* and the AppShell stylesheet. See CHUNK #1 step 3.
|
||||
*/
|
||||
|
||||
export * from "./theme.js";
|
||||
export * from "./fonts.js";
|
||||
export * from "./components/index.js";
|
||||
|
||||
import "./AppShell.css";
|
||||
@@ -0,0 +1,49 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { THEME_IDS, THEME_LABELS, THEME_TOKENS, getThemeTokens, applyTheme, getActiveTheme } from "./theme.js";
|
||||
|
||||
describe("theme token map", () => {
|
||||
it("defines exactly four themes", () => {
|
||||
expect(THEME_IDS).toEqual(["ink", "parchment", "nocturne", "terminal"]);
|
||||
expect(Object.keys(THEME_TOKENS)).toHaveLength(4);
|
||||
});
|
||||
|
||||
it("labels every theme", () => {
|
||||
for (const id of THEME_IDS) {
|
||||
expect(THEME_LABELS[id]).toBeTruthy();
|
||||
}
|
||||
});
|
||||
|
||||
it("each theme has a full set of color tokens", () => {
|
||||
const required = [
|
||||
"--ps-bg",
|
||||
"--ps-surface",
|
||||
"--ps-surface-2",
|
||||
"--ps-border",
|
||||
"--ps-text",
|
||||
"--ps-text-muted",
|
||||
"--ps-accent",
|
||||
"--ps-accent-contrast",
|
||||
"--ps-danger",
|
||||
"--ps-success",
|
||||
"--ps-warning",
|
||||
];
|
||||
for (const id of THEME_IDS) {
|
||||
const css = THEME_TOKENS[id]!.css;
|
||||
for (const key of required) {
|
||||
expect(css[key], `${id} missing ${key}`).toBeTruthy();
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it("getThemeTokens throws on unknown id", () => {
|
||||
expect(() => getThemeTokens("nope" as never)).toThrow(/Unknown theme/);
|
||||
});
|
||||
|
||||
it("applyTheme sets the active theme and returns it", () => {
|
||||
expect(applyTheme("parchment")).toBe("parchment");
|
||||
expect(getActiveTheme()).toBe("parchment");
|
||||
// reset for other tests
|
||||
applyTheme("ink");
|
||||
expect(getActiveTheme()).toBe("ink");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,132 @@
|
||||
/**
|
||||
* Theme engine — CSS-variable token map. See PLAN.md §4.7 and CHUNK #1 step 3.
|
||||
*
|
||||
* Four themes ship as token objects; `applyTheme(id)` sets
|
||||
* `document.documentElement.dataset.theme`. Accent color and density are
|
||||
* runtime overrides layered on top.
|
||||
*/
|
||||
|
||||
export type ThemeId = "ink" | "parchment" | "nocturne" | "terminal";
|
||||
|
||||
export const THEME_IDS: ThemeId[] = ["ink", "parchment", "nocturne", "terminal"];
|
||||
|
||||
export const THEME_LABELS: Record<ThemeId, string> = {
|
||||
ink: "Ink",
|
||||
parchment: "Parchment",
|
||||
nocturne: "Nocturne",
|
||||
terminal: "Terminal",
|
||||
};
|
||||
|
||||
export interface ThemeTokens {
|
||||
label: string;
|
||||
/** CSS custom properties applied to :root when this theme is active. */
|
||||
css: Record<string, string>;
|
||||
}
|
||||
|
||||
/**
|
||||
* The four themes. Ink is the default (dark). Parchment is warm light,
|
||||
* Nocturne is deep indigo, Terminal is monochrome monospace.
|
||||
*/
|
||||
export const THEME_TOKENS: Record<ThemeId, ThemeTokens> = {
|
||||
ink: {
|
||||
label: "Ink",
|
||||
css: {
|
||||
"--ps-bg": "#0e0f13",
|
||||
"--ps-surface": "#17181f",
|
||||
"--ps-surface-2": "#20222c",
|
||||
"--ps-border": "#2a2d3a",
|
||||
"--ps-text": "#e6e8ee",
|
||||
"--ps-text-muted": "#9aa0b0",
|
||||
"--ps-accent": "#7c8bf5",
|
||||
"--ps-accent-contrast": "#0e0f13",
|
||||
"--ps-danger": "#f16d7a",
|
||||
"--ps-success": "#5fd08a",
|
||||
"--ps-warning": "#f0b754",
|
||||
},
|
||||
},
|
||||
parchment: {
|
||||
label: "Parchment",
|
||||
css: {
|
||||
"--ps-bg": "#f6f1e7",
|
||||
"--ps-surface": "#fffdf7",
|
||||
"--ps-surface-2": "#f2ead8",
|
||||
"--ps-border": "#e2d6bd",
|
||||
"--ps-text": "#2c2720",
|
||||
"--ps-text-muted": "#6f6656",
|
||||
"--ps-accent": "#b5793a",
|
||||
"--ps-accent-contrast": "#fffdf7",
|
||||
"--ps-danger": "#c0392b",
|
||||
"--ps-success": "#2e7d4f",
|
||||
"--ps-warning": "#b8860b",
|
||||
},
|
||||
},
|
||||
nocturne: {
|
||||
label: "Nocturne",
|
||||
css: {
|
||||
"--ps-bg": "#0c0b1e",
|
||||
"--ps-surface": "#171533",
|
||||
"--ps-surface-2": "#221f4a",
|
||||
"--ps-border": "#2e2a5e",
|
||||
"--ps-text": "#e8e6ff",
|
||||
"--ps-text-muted": "#a49fcf",
|
||||
"--ps-accent": "#9d7bff",
|
||||
"--ps-accent-contrast": "#0c0b1e",
|
||||
"--ps-danger": "#ff7b9c",
|
||||
"--ps-success": "#6ee7a8",
|
||||
"--ps-warning": "#ffd27b",
|
||||
},
|
||||
},
|
||||
terminal: {
|
||||
label: "Terminal",
|
||||
css: {
|
||||
"--ps-bg": "#05070a",
|
||||
"--ps-surface": "#0a0f14",
|
||||
"--ps-surface-2": "#0f1720",
|
||||
"--ps-border": "#16232e",
|
||||
"--ps-text": "#33ff9b",
|
||||
"--ps-text-muted": "#2c8a63",
|
||||
"--ps-accent": "#33ff9b",
|
||||
"--ps-accent-contrast": "#05070a",
|
||||
"--ps-danger": "#ff6b6b",
|
||||
"--ps-success": "#33ff9b",
|
||||
"--ps-warning": "#ffd93d",
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
/** The active theme id, persisted by the settings store. */
|
||||
let activeTheme: ThemeId = "ink";
|
||||
|
||||
/** Return the token map for a theme id (throws on unknown). */
|
||||
export function getThemeTokens(id: ThemeId): ThemeTokens {
|
||||
const t = THEME_TOKENS[id];
|
||||
if (!t) {
|
||||
throw new Error(`Unknown theme: ${id}`);
|
||||
}
|
||||
return t;
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply a theme by setting the data attribute and writing its CSS variables to
|
||||
* :root. Returns the applied id. Safe to call before the DOM exists (no-op).
|
||||
*/
|
||||
export function applyTheme(id: ThemeId): ThemeId {
|
||||
if (!THEME_TOKENS[id]) {
|
||||
throw new Error(`Unknown theme: ${id}`);
|
||||
}
|
||||
activeTheme = id;
|
||||
const root = typeof document !== "undefined" ? document.documentElement : undefined;
|
||||
if (root) {
|
||||
root.dataset.theme = id;
|
||||
const { css } = THEME_TOKENS[id]!;
|
||||
for (const [k, v] of Object.entries(css)) {
|
||||
root.style.setProperty(k, v);
|
||||
}
|
||||
}
|
||||
return id;
|
||||
}
|
||||
|
||||
/** The currently active theme id. */
|
||||
export function getActiveTheme(): ThemeId {
|
||||
return activeTheme;
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"extends": "../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"outDir": "./dist",
|
||||
"rootDir": "./src",
|
||||
"jsx": "react-jsx"
|
||||
},
|
||||
"include": ["src/**/*.ts", "src/**/*.tsx"]
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
import { defineConfig } from "vitest/config";
|
||||
|
||||
export default defineConfig({
|
||||
test: {
|
||||
globals: true,
|
||||
environment: "node",
|
||||
include: ["src/**/*.test.ts"],
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user