fix(core): design.md lives on PenDocument — kill cross-document leak
design.md was stored in a global Zustand store + per-file-key localStorage in apps/web, and in a module-level cache in pen-mcp. Both leaked across files: a newly-created document could pick up the previous file's dark palette (async clearForNewDocument raced with AI chat reads; hydrate() could rehydrate the last file's designMd on refresh; shared .pen files lost the spec entirely because it wasn't inside the document). Fix: - Add `designMd?: DesignMdSpec` to PenDocument (pen-types). It now serializes with .pen/.op and travels across sessions/users. - Add `setDesignMd` action to document-store. - Rewrite design-md-store as a thin mirror over document-store so the legacy hook API still works. On document load it migrates any legacy localStorage entry into the opened document and deletes the localStorage key; hydrate() wipes the orphan `openpencil-design-md-current-key`. - MCP handleGetDesignMd / handleSetDesignMd / handleExportDesignMd read `doc.designMd` directly and persist via saveDocument. Removed the process-level `_mcpDesignMd` cache. Verified via MCP live round-trip: set on file A → persists to A's .op on disk → new file B returns hasDesignMd:false (no leak).
This commit is contained in:
parent
bc7e16fa20
commit
2aabe494f5
|
|
@ -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:<fileKey>`) 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<DesignMdStoreState>((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<DesignMdStoreState>((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 */
|
||||
}
|
||||
},
|
||||
};
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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<string, string[]>) => 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<DocumentStoreState>((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) => {
|
||||
|
|
|
|||
|
|
@ -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) };
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<string, string[]>;
|
||||
variables?: Record<string, VariableDefinition>;
|
||||
/**
|
||||
* 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[];
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue