diff --git a/apps/web/src/stores/design-md-store.ts b/apps/web/src/stores/design-md-store.ts index 4e8dada94..f14908b56 100644 --- a/apps/web/src/stores/design-md-store.ts +++ b/apps/web/src/stores/design-md-store.ts @@ -1,108 +1,117 @@ import { create } from 'zustand'; import type { DesignMdSpec } from '@/types/design-md'; import { appStorage } from '@/utils/app-storage'; +import { useDocumentStore } from '@/stores/document-store'; -const STORAGE_PREFIX = 'openpencil-design-md:'; -const CURRENT_KEY_STORAGE = 'openpencil-design-md-current-key'; +/** + * Design.md lives on `PenDocument.designMd` — per-document, serialized with + * the .pen/.op file, travels with save/load, no cross-document leak. + * + * This store is a thin mirror over `document-store.document.designMd` to + * preserve the legacy hook API (`useDesignMdStore(s => s.designMd)` and + * `useDesignMdStore(s => s.setDesignMd)`) used throughout the editor UI. + * + * Legacy localStorage keys (`openpencil-design-md:`) are migrated + * into `document.designMd` on first load of a matching file, then deleted. + * After the migration window closes (a future release), this adapter can + * be removed entirely and callers can hit document-store directly. + */ + +const LEGACY_STORAGE_PREFIX = 'openpencil-design-md:'; +const LEGACY_CURRENT_KEY = 'openpencil-design-md-current-key'; + +interface DesignMdStoreState { + designMd: DesignMdSpec | undefined; + setDesignMd: (spec: DesignMdSpec | undefined) => void; + /** + * Called on document load — attempts to migrate legacy per-file + * localStorage design.md into the opened document if the document has + * no designMd yet. No-op on untitled docs or when nothing to migrate. + */ + syncToDocument: (fileName: string | null, filePath: string | null) => void; + /** Called on new document. Kept for call-site back-compat; no-op now. */ + clearForNewDocument: () => void; + /** Called once at app start. Wipes the obsolete `CURRENT_KEY_STORAGE` entry. */ + hydrate: () => void; +} -/** Derive a storage key from a file identifier. Returns null for untitled documents. */ function fileKey(fileName: string | null, filePath: string | null): string | null { return filePath ?? fileName ?? null; } -interface DesignMdStoreState { - /** Current design.md spec */ - designMd: DesignMdSpec | undefined; - /** Current file key for persistence (null = untitled, skip persistence) */ - _fileKey: string | null; - - setDesignMd: (spec: DesignMdSpec | undefined) => void; - /** Sync store to a document — restores persisted designMd or clears if none. */ - syncToDocument: (fileName: string | null, filePath: string | null) => void; - /** Called on new document — clears designMd. */ - clearForNewDocument: () => void; - hydrate: () => void; +function readLegacySpec(key: string): DesignMdSpec | null { + try { + const raw = appStorage.getItem(LEGACY_STORAGE_PREFIX + key); + if (!raw) return null; + const data = JSON.parse(raw) as DesignMdSpec; + if (data && typeof data === 'object' && typeof data.raw === 'string') { + return data; + } + } catch { + /* ignore */ + } + return null; } -export const useDesignMdStore = create((set, get) => ({ - designMd: undefined, - _fileKey: null, - - setDesignMd: (spec) => { - set({ designMd: spec }); - const key = get()._fileKey; - if (!key) return; // untitled — skip persistence - try { - if (spec) { - appStorage.setItem(STORAGE_PREFIX + key, JSON.stringify(spec)); - } else { - appStorage.removeItem(STORAGE_PREFIX + key); - } - } catch { - /* ignore */ - } - }, - - syncToDocument: (fileName, filePath) => { - const key = fileKey(fileName, filePath); - set({ _fileKey: key }); - - if (!key) { - set({ designMd: undefined }); - return; - } - - // Restore persisted designMd for this file - try { - const raw = appStorage.getItem(STORAGE_PREFIX + key); - if (raw) { - const data = JSON.parse(raw) as DesignMdSpec; - if (data && typeof data === 'object' && typeof data.raw === 'string') { - set({ designMd: data }); - return; - } - } - } catch { - /* ignore */ - } - - set({ designMd: undefined }); - }, - - clearForNewDocument: () => { - set({ designMd: undefined, _fileKey: null }); - }, - - hydrate: () => { - try { - const lastKey = appStorage.getItem(CURRENT_KEY_STORAGE); - if (!lastKey) return; - set({ _fileKey: lastKey }); - const raw = appStorage.getItem(STORAGE_PREFIX + lastKey); - if (!raw) return; - const data = JSON.parse(raw) as DesignMdSpec; - if (data && typeof data === 'object' && typeof data.raw === 'string') { - set({ designMd: data }); - } - } catch { - /* ignore */ - } - }, -})); - -// Persist the current file key whenever state changes -let _prevFileKey: string | null = null; -useDesignMdStore.subscribe((state) => { - if (state._fileKey !== _prevFileKey) { - _prevFileKey = state._fileKey; - try { - if (state._fileKey) { - appStorage.setItem(CURRENT_KEY_STORAGE, state._fileKey); - } else { - appStorage.removeItem(CURRENT_KEY_STORAGE); - } - } catch { - /* ignore */ - } +function clearLegacyEntry(key: string): void { + try { + appStorage.removeItem(LEGACY_STORAGE_PREFIX + key); + } catch { + /* ignore */ } +} + +export const useDesignMdStore = create((set) => { + // Seed from document-store's current doc, then subscribe for changes so + // UI components selecting `designMd` re-render when the underlying + // document mutates (e.g. after `loadDocument`, undo/redo, MCP sync). + set({ designMd: useDocumentStore.getState().document.designMd }); + useDocumentStore.subscribe((state, prev) => { + if (state.document.designMd !== prev.document.designMd) { + set({ designMd: state.document.designMd }); + } + }); + + return { + designMd: undefined, + + setDesignMd: (spec) => { + useDocumentStore.getState().setDesignMd(spec); + }, + + syncToDocument: (fileName, filePath) => { + const key = fileKey(fileName, filePath); + if (!key) return; + + // Migrate legacy per-file localStorage entry into the document once. + const docState = useDocumentStore.getState(); + if (docState.document.designMd) { + // Document already carries its own designMd — legacy entry is + // obsolete, just delete it. + clearLegacyEntry(key); + return; + } + const legacy = readLegacySpec(key); + if (legacy) { + docState.setDesignMd(legacy); + clearLegacyEntry(key); + } + }, + + clearForNewDocument: () => { + // No-op: `newDocument()` produces a fresh PenDocument whose + // `designMd` is already undefined. The zustand subscription will + // pick that up automatically. + }, + + hydrate: () => { + // Wipe the orphan "current file" pointer from the legacy scheme so + // it cannot repopulate designMd across sessions. + try { + appStorage.removeItem(LEGACY_CURRENT_KEY); + } catch { + /* ignore */ + } + }, + }; }); diff --git a/apps/web/src/stores/document-store.ts b/apps/web/src/stores/document-store.ts index 20a173708..8b4695910 100644 --- a/apps/web/src/stores/document-store.ts +++ b/apps/web/src/stores/document-store.ts @@ -1,6 +1,7 @@ import { create } from 'zustand'; import type { PenDocument, PenNode } from '@/types/pen'; import type { VariableDefinition } from '@/types/variables'; +import type { DesignMdSpec } from '@/types/design-md'; import { normalizePenDocument } from '@/utils/normalize-pen-file'; import { addRecentFile } from '@/utils/recent-files'; @@ -69,6 +70,9 @@ interface DocumentStoreState { renameVariable: (oldName: string, newName: string) => void; setThemes: (themes: Record) => void; + // Design.md — per-document design system spec (lives inside PenDocument). + setDesignMd: (spec: DesignMdSpec | undefined) => void; + // Page management addPage: () => string; removePage: (pageId: string) => void; @@ -127,6 +131,15 @@ export const useDocumentStore = create((set, get) => ({ // --- Page management (extracted to document-store-pages.ts) --- ...createPageActions(set, get), + // --- Design.md (per-document) --- + setDesignMd: (spec) => { + useHistoryStore.getState().pushState(get().document); + set((s) => ({ + document: { ...s.document, designMd: spec }, + isDirty: true, + })); + }, + // --- Lifecycle actions (remain inline — small) --- applyExternalDocument: (doc) => { diff --git a/packages/pen-mcp/src/tools/design-md.ts b/packages/pen-mcp/src/tools/design-md.ts index 9d6085e39..13e078b24 100644 --- a/packages/pen-mcp/src/tools/design-md.ts +++ b/packages/pen-mcp/src/tools/design-md.ts @@ -1,4 +1,4 @@ -import { openDocument, resolveDocPath } from '../document-manager'; +import { openDocument, resolveDocPath, saveDocument } from '../document-manager'; import { parseDesignMd, generateDesignMd, @@ -7,9 +7,16 @@ import { import type { DesignMdSpec } from '@zseven-w/pen-types'; import { setDesignMdForPrompt } from './design-prompt'; -// In MCP context (stdio mode), there's no Zustand store. -// We keep a module-level cache for the design.md spec. -let _mcpDesignMd: DesignMdSpec | undefined; +/** + * design.md is now stored on the PenDocument (`doc.designMd`). It travels + * with `.op`/`.pen` files and lives per-document. These handlers read + * directly from the opened document and write via `saveDocument`. + * + * When `doc.designMd` is absent we auto-extract a spec from the document's + * variables/typography for display purposes only — extraction is a + * best-effort reverse inference, NOT persisted back unless the caller + * explicitly sets it. + */ export interface GetDesignMdParams { filePath?: string; @@ -27,46 +34,48 @@ export interface ExportDesignMdParams { filePath?: string; } -/** Read the design.md spec. */ +function specHasContent(spec: DesignMdSpec): boolean { + return !!(spec.colorPalette?.length || spec.typography?.fontFamily || spec.visualTheme); +} + +/** Read the design.md spec from the document (falls back to extraction). */ export async function handleGetDesignMd( params: GetDesignMdParams, ): Promise<{ hasDesignMd: boolean; spec?: DesignMdSpec; markdown?: string }> { - // Try module cache first - if (_mcpDesignMd) { + const filePath = resolveDocPath(params.filePath); + const doc = await openDocument(filePath); + + if (doc.designMd) { + setDesignMdForPrompt(doc.designMd); return { hasDesignMd: true, - spec: _mcpDesignMd, - markdown: generateDesignMd(_mcpDesignMd), + spec: doc.designMd, + markdown: generateDesignMd(doc.designMd), }; } - // Try to auto-extract from document - const filePath = resolveDocPath(params.filePath); - const doc = await openDocument(filePath); - const spec = extractDesignMdFromDocument(doc); - const hasContent = !!( - spec.colorPalette?.length || - spec.typography?.fontFamily || - spec.visualTheme - ); - - if (hasContent) { - _mcpDesignMd = spec; - return { hasDesignMd: true, spec, markdown: generateDesignMd(spec) }; + // Fallback: auto-extract from document variables/typography. Not persisted. + const extracted = extractDesignMdFromDocument(doc); + if (specHasContent(extracted)) { + return { + hasDesignMd: true, + spec: extracted, + markdown: generateDesignMd(extracted), + }; } return { hasDesignMd: false }; } -/** Import design.md content. */ +/** Write design.md into the document (persisted via saveDocument). */ export async function handleSetDesignMd( params: SetDesignMdParams, ): Promise<{ success: boolean; spec?: DesignMdSpec }> { - let spec: DesignMdSpec; + const filePath = resolveDocPath(params.filePath); + const doc = await openDocument(filePath); + let spec: DesignMdSpec; if (params.autoExtract) { - const filePath = resolveDocPath(params.filePath); - const doc = await openDocument(filePath); spec = extractDesignMdFromDocument(doc); } else if (params.markdown) { spec = parseDesignMd(params.markdown); @@ -74,23 +83,24 @@ export async function handleSetDesignMd( return { success: false }; } - _mcpDesignMd = spec; + doc.designMd = spec; + await saveDocument(filePath, doc); setDesignMdForPrompt(spec); return { success: true, spec }; } -/** Export design.md as markdown text. */ +/** Export design.md as markdown text (reads from doc.designMd first). */ export async function handleExportDesignMd( params: ExportDesignMdParams, ): Promise<{ markdown: string }> { - if (_mcpDesignMd) { - return { markdown: generateDesignMd(_mcpDesignMd) }; - } - - // Auto-extract from document const filePath = resolveDocPath(params.filePath); const doc = await openDocument(filePath); + + if (doc.designMd) { + return { markdown: generateDesignMd(doc.designMd) }; + } + const spec = extractDesignMdFromDocument(doc); return { markdown: generateDesignMd(spec) }; } diff --git a/packages/pen-types/src/pen.ts b/packages/pen-types/src/pen.ts index ae83ca2c3..5a7e627a7 100644 --- a/packages/pen-types/src/pen.ts +++ b/packages/pen-types/src/pen.ts @@ -1,5 +1,6 @@ import type { PenFill, PenStroke, PenEffect, StyledTextSegment } from './styles.js'; import type { VariableDefinition } from './variables.js'; +import type { DesignMdSpec } from './design-md.js'; // --- Page --- @@ -16,6 +17,13 @@ export interface PenDocument { name?: string; themes?: Record; variables?: Record; + /** + * Design system specification attached to this document. Lives on the + * document (not in a side store) so it serializes with `.pen`/`.op` + * files and travels between sessions/users without leaking across + * documents (see `openpencil-docs/…/plans/2026-04-20-design-md-per-document.md`). + */ + designMd?: DesignMdSpec; pages?: PenPage[]; children: PenNode[]; }