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:
Fini 2026-04-20 00:37:31 +08:00
parent bc7e16fa20
commit 2aabe494f5
4 changed files with 168 additions and 128 deletions

View file

@ -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 */
}
},
};
});

View file

@ -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) => {

View file

@ -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) };
}

View file

@ -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[];
}