From 5623c925fb90dc177bbfc1f1796b377de48f81ea Mon Sep 17 00:00:00 2001 From: Justineo Date: Fri, 16 Oct 2026 12:54:11 +0800 Subject: [PATCH 001/351] fix(extension): bound cancelled task cleanup retries --- docs/extension/mcp-design-tasks.md | 5 +- .../extension/components/DesignTaskStatus.vue | 25 +++++- .../design-task-status.browser.test.ts | 83 +++++++++++++++++++ 3 files changed, 108 insertions(+), 5 deletions(-) diff --git a/docs/extension/mcp-design-tasks.md b/docs/extension/mcp-design-tasks.md index a6bea83a..f7454b7b 100644 --- a/docs/extension/mcp-design-tasks.md +++ b/docs/extension/mcp-design-tasks.md @@ -136,7 +136,10 @@ Done persists a closure fence before clearing drafts. It propagates through reco and worker restarts, and notifies other tabs locally even without a Hub. Late messages, autosaves, and delivery receipts cannot reopen or clear a later round. Stop clears unsent comments and dismisses cancelled controls after pending work and local saves -settle. This is local persistence, not cross-device history. +settle. If cleanup fails, cancellation remains in effect and Done retries cleanup +explicitly, without confirmation; task refreshes do not retry it automatically. +The panel retains that recovery action even without a usable anchor. This is local +persistence, not cross-device history. ## Host integration diff --git a/packages/extension/components/DesignTaskStatus.vue b/packages/extension/components/DesignTaskStatus.vue index eb54390d..54130fac 100644 --- a/packages/extension/components/DesignTaskStatus.vue +++ b/packages/extension/components/DesignTaskStatus.vue @@ -45,6 +45,7 @@ const stoppable = computed( const dismissBlocked = shallowRef(false) const feedbackComponent = shallowRef | null>(null) const finishing = shallowRef(false) +const cancelledCleanupFailed = shallowRef(false) const confirmationOpen = shallowRef(false) function requestStop() { @@ -86,18 +87,24 @@ async function done(confirmed = false) { if (!taskId || stoppable.value || dismissBlocked.value || exitPhase.value || finishing.value) return finishing.value = true + cancelledCleanupFailed.value = false + let cleared = false try { const completed = props.task?.status === 'completed' const close = completed && props.closeReview ? () => props.closeReview!(taskId) : undefined if (feedbackComponent.value) { if ((await feedbackComponent.value.prepareDismiss(close)) === false) return } else await close?.() + cleared = true if (props.task?.taskId !== taskId || stoppable.value || dismissBlocked.value) return } catch (error) { showError(error instanceof Error ? error.message : 'Could not close this review.') return } finally { - if (props.task?.taskId === taskId) finishing.value = false + if (props.task?.taskId === taskId) { + cancelledCleanupFailed.value = props.task.status === 'cancelled' && !cleared + finishing.value = false + } } const finish = () => { dismissed.value = true @@ -123,6 +130,7 @@ watch( exitPhase.value = null dismissed.value = false finishing.value = false + cancelledCleanupFailed.value = false confirmationOpen.value = false retainedAnchor.value = null } @@ -140,7 +148,9 @@ const visible = computed( !!props.task && !dismissed.value && sameFile.value && - (active.value || (local.value && (props.task.status !== 'cancelled' || hasAnchor.value))) + (active.value || + (local.value && + (props.task.status !== 'cancelled' || hasAnchor.value || cancelledCleanupFailed.value))) ) const executing = computed(() => active.value && !!props.task?.operation) const canLocate = computed(() => visible.value && local.value && hasAnchor.value) @@ -208,7 +218,14 @@ onMounted(() => { async () => { // Let the feedback child report restored drafts and pending edits before closing. await nextTick() - if (props.task?.status === 'cancelled' && local.value && !dismissed.value) void done() + // A failed clear needs an explicit retry, not its own busy-state transition. + if ( + props.task?.status === 'cancelled' && + local.value && + !dismissed.value && + !cancelledCleanupFailed.value + ) + void done() }, { immediate: true, flush: 'post' } ) @@ -492,7 +509,7 @@ onScopeDispose(() => { Stop -

- - - - +

{{ showLoading ? 'Generating code…' : '' }}

+
+

+ {{ codeError }} +

+ + + + +
diff --git a/packages/extension/tests/components/plugins-section.browser.test.ts b/packages/extension/tests/components/plugins-section.browser.test.ts index 0e33f4b1..9332701f 100644 --- a/packages/extension/tests/components/plugins-section.browser.test.ts +++ b/packages/extension/tests/components/plugins-section.browser.test.ts @@ -7,7 +7,7 @@ vi.mock('@/utils/codegen', async (original) => ({ })) import PluginsSection from '@/components/sections/PluginsSection.vue' -import { options, plugins } from '@/ui/state' +import { options, plugins, pluginStorageCorrupted } from '@/ui/state' import { codegen } from '@/utils/codegen' import { mount, unmountAll } from './mount' @@ -29,6 +29,7 @@ beforeEach(async () => { vi.stubGlobal(name, value) } plugins.value = {} + pluginStorageCorrupted.value = false options.value.activePluginSource = null await nextTick() notify.mockClear() @@ -47,6 +48,67 @@ afterEach(() => { vi.unstubAllGlobals() }) +it('offers explicit corruption recovery, retains failed resets, and permits a fresh install', async () => { + vi.spyOn(console, 'error').mockImplementation(() => {}) + localStorage.setItem('tempad-dev-plugins', '{broken') + options.value.activePluginSource = source + window.dispatchEvent( + new StorageEvent('storage', { key: 'tempad-dev-plugins', storageArea: localStorage }) + ) + const host = mount(PluginsSection) + await expect.poll(() => host.textContent).toContain('Stored plugin data is invalid.') + expect(host.textContent).toContain('Resetting removes installed code-output plugins.') + expect(localStorage.getItem('tempad-dev-plugins')).toBe('{broken') + const nativeSetItem = Storage.prototype.setItem + let rejectWrite = true + vi.spyOn(Storage.prototype, 'setItem').mockImplementation(function (this: Storage, key, value) { + if (key === 'tempad-dev-plugins' && rejectWrite) + throw new DOMException('Storage quota exceeded', 'QuotaExceededError') + nativeSetItem.call(this, key, value) + }) + const reset = host.querySelector('.tp-plugins-reset')! + reset.click() + await expect + .poll(() => notify.mock.calls.map(([message]) => message)) + .toContain('Could not reset plugins. Browser storage may be full or unavailable.') + expect(localStorage.getItem('tempad-dev-plugins')).toBe('{broken') + expect(options.value.activePluginSource).toBe(source) + expect(host.contains(reset)).toBe(true) + rejectWrite = false + reset.click() + await expect.poll(() => localStorage.getItem('tempad-dev-plugins')).toBe('{}') + await expect.poll(() => host.querySelector('.tp-plugins-reset')).toBeNull() + expect(options.value.activePluginSource).toBeNull() + + host.querySelector('.tp-plugins-add')!.click() + await nextTick() + const input = host.querySelector('input[type="text"]')! + input.value = source + input.dispatchEvent(new Event('input', { bubbles: true })) + await nextTick() + host.querySelector('.tp-action-button')!.click() + await expect.poll(() => plugins.value[source]?.name).toBe('New plugin') + expect(options.value.activePluginSource).toBe(source) +}) + +it('identifies corruption discovered during installation without replacing the invalid record', async () => { + const host = mount(PluginsSection) + host.querySelector('.tp-plugins-add')!.click() + await nextTick() + const input = host.querySelector('input[type="text"]')! + input.value = source + input.dispatchEvent(new Event('input', { bubbles: true })) + await nextTick() + localStorage.setItem('tempad-dev-plugins', '{broken') + host.querySelector('.tp-action-button')!.click() + await expect + .poll(() => notify.mock.calls.map(([message]) => message)) + .toContain('Stored plugin data is invalid. Reset plugins to continue.') + expect(localStorage.getItem('tempad-dev-plugins')).toBe('{broken') + expect(host.querySelector('.tp-plugins-reset')).not.toBeNull() + expect(options.value.activePluginSource).toBeNull() +}) + it.each(['install', 'update', 'remove'] as const)( 'preserves plugin and active state when %s cannot be saved, then retries durably', async (action) => { diff --git a/packages/extension/tests/ui/plugin-storage.browser.test.ts b/packages/extension/tests/ui/plugin-storage.browser.test.ts index 9e4e07a4..79ab3ee2 100644 --- a/packages/extension/tests/ui/plugin-storage.browser.test.ts +++ b/packages/extension/tests/ui/plugin-storage.browser.test.ts @@ -2,7 +2,11 @@ import { useStorage } from '@vueuse/core' import { afterEach, expect, it, vi } from 'vitest' import { effectScope, nextTick } from 'vue' -import { createPluginStorage, PLUGIN_STORAGE_KEY } from '@/ui/plugin-storage' +import { + CorruptPluginStorageError, + createPluginStorage, + PLUGIN_STORAGE_KEY +} from '@/ui/plugin-storage' import { createPreferenceStorage } from '@/ui/preference-storage' const plugin = (name: string) => ({ name, source: name, code: `${name} code` }) @@ -53,11 +57,79 @@ it('serializes legacy migration with edits and never reapplies a stale legacy ba expect(second.read().plugins).toEqual({ new: plugin('new') }) }) -it('preserves malformed persisted data and leaves recovery to an explicit action', async () => { +it.each(['{broken', '[]', 'null'])( + 'preserves invalid persisted data (%s) until explicit recovery', + async (record) => { + localStorage.setItem(PLUGIN_STORAGE_KEY, record) + const storage = createPluginStorage(localStorage, navigator.locks) + expect(() => storage.read()).toThrow(CorruptPluginStorageError) + await expect(storage.migrate()).rejects.toThrow(CorruptPluginStorageError) + await expect(storage.save('new', plugin('new'))).rejects.toThrow(CorruptPluginStorageError) + expect(localStorage.getItem(PLUGIN_STORAGE_KEY)).toBe(record) + await expect(storage.resetCorruptRecord()).resolves.toBe(true) + expect(storage.read().plugins).toEqual({}) + } +) + +it('serializes recovery with installation and prevents a legacy backup from returning', async () => { + localStorage.setItem(PLUGIN_STORAGE_KEY, '{broken') + localStorage.setItem( + 'tempad-dev', + JSON.stringify({ minimized: true, plugins: { old: plugin('old') } }) + ) + const frame = document.createElement('iframe') + document.body.append(frame) + const remote = frame.contentWindow! + const first = createPluginStorage(localStorage, navigator.locks) + const second = createPluginStorage(remote.localStorage, remote.navigator.locks) + const entered = Promise.withResolvers() + const release = Promise.withResolvers() + const held = navigator.locks.request(PLUGIN_STORAGE_KEY, () => { + entered.resolve() + return release.promise + }) + await entered.promise + const reset = first.resetCorruptRecord() + expect( + (await navigator.locks.query()).pending?.some(({ name }) => name === PLUGIN_STORAGE_KEY) + ).toBe(true) + const install = second.save('new', plugin('new')) + const whileLocked = localStorage.getItem(PLUGIN_STORAGE_KEY) + release.resolve() + await Promise.all([held, reset, install]) + expect(whileLocked).toBe('{broken') + expect(first.read().plugins).toEqual({ new: plugin('new') }) + expect(JSON.parse(localStorage.getItem('tempad-dev')!)).toEqual({ minimized: true }) + // A stale recovery action must not clear the other tab's newly installed plugin. + await expect(first.resetCorruptRecord()).resolves.toBe(false) + expect(first.read().plugins).toEqual({ new: plugin('new') }) +}) + +it('keeps a retained legacy backup fenced when its cleanup is unavailable', async () => { localStorage.setItem(PLUGIN_STORAGE_KEY, '{broken') + const legacy = JSON.stringify({ plugins: { old: plugin('old') } }) + localStorage.setItem('tempad-dev', legacy) + const nativeSetItem = Storage.prototype.setItem + vi.spyOn(Storage.prototype, 'setItem').mockImplementation(function (this: Storage, key, value) { + if (key === 'tempad-dev') throw new DOMException('Storage unavailable', 'SecurityError') + nativeSetItem.call(this, key, value) + }) + const storage = createPluginStorage(localStorage, navigator.locks) + await expect(storage.resetCorruptRecord()).resolves.toBe(true) + await storage.migrate() + expect(localStorage.getItem('tempad-dev')).toBe(legacy) + expect(storage.read().plugins).toEqual({}) +}) + +it('does not classify inaccessible storage as corruption or reset it', async () => { + const read = vi.spyOn(Storage.prototype, 'getItem').mockImplementation(() => { + throw new DOMException('Storage unavailable', 'SecurityError') + }) + const write = vi.spyOn(Storage.prototype, 'setItem') const storage = createPluginStorage(localStorage, navigator.locks) - await expect(storage.save('new', plugin('new'))).rejects.toThrow() - expect(localStorage.getItem(PLUGIN_STORAGE_KEY)).toBe('{broken') + await expect(storage.resetCorruptRecord()).rejects.toThrow('Storage unavailable') + expect(write).not.toHaveBeenCalled() + read.mockRestore() }) it('keeps legacy deletions durable when another tab persists stale preferences under quota pressure', async () => { diff --git a/packages/extension/ui/plugin-storage.ts b/packages/extension/ui/plugin-storage.ts index d491a4bc..845cc4ed 100644 --- a/packages/extension/ui/plugin-storage.ts +++ b/packages/extension/ui/plugin-storage.ts @@ -11,13 +11,22 @@ const PREFERENCE_STORAGE_KEY = 'tempad-dev' type PluginRecord = Record +export class CorruptPluginStorageError extends Error { + constructor() { + super('Stored plugin data is invalid.') + } +} + function parseRecord(value: string | null): Record { if (value === null) return {} - const parsed: unknown = JSON.parse(value) - if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) { - throw new Error('Invalid plugin storage record.') + try { + const parsed: unknown = JSON.parse(value) + if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) + return parsed as Record + } catch { + // Preserve the invalid record until the user explicitly resets it. } - return parsed as Record + throw new CorruptPluginStorageError() } export function createPluginStorage(storage: Storage, locks: LockManager) { @@ -73,5 +82,20 @@ export function createPluginStorage(storage: Storage, locks: LockManager) { }) } - return { read, migrate, save } + async function resetCorruptRecord() { + return locks.request(PLUGIN_STORAGE_KEY, () => { + try { + read() + // Another tab may have recovered the record while this action was queued. + return false + } catch (error) { + if (!(error instanceof CorruptPluginStorageError)) throw error + } + storage.setItem(PLUGIN_STORAGE_KEY, '{}') + removeLegacyBackup() + return true + }) + } + + return { read, migrate, save, resetCorruptRecord } } diff --git a/packages/extension/ui/state.ts b/packages/extension/ui/state.ts index 9dbe5d27..998235eb 100644 --- a/packages/extension/ui/state.ts +++ b/packages/extension/ui/state.ts @@ -5,7 +5,12 @@ import { getTemPadComponent } from '@/utils' import { logger } from '@/utils/log' import { ui } from './figma' -import { createPluginStorage, PLUGIN_STORAGE_KEY, type PluginData } from './plugin-storage' +import { + CorruptPluginStorageError, + createPluginStorage, + PLUGIN_STORAGE_KEY, + type PluginData +} from './plugin-storage' import { createPreferenceStorage } from './preference-storage' export type Options = { @@ -55,9 +60,11 @@ export const options = useStorage( const pluginStorage = createPluginStorage(localStorage, navigator.locks) export const plugins = shallowRef>({}) +export const pluginStorageCorrupted = shallowRef(false) function publishPlugins(current: ReturnType) { plugins.value = current.plugins + pluginStorageCorrupted.value = false const preferences = options.value as Options & { plugins?: typeof plugins.value } if (!current.legacy) delete preferences.plugins } @@ -66,6 +73,7 @@ function refreshPlugins() { try { publishPlugins(pluginStorage.read()) } catch (error) { + if (error instanceof CorruptPluginStorageError) pluginStorageCorrupted.value = true logger.error('Failed to read plugin storage:', error) } } @@ -90,8 +98,19 @@ useEventListener(window, 'storage', (event) => { }) export async function savePlugin(source: string, plugin: PluginData | null): Promise { - await pluginStorage.save(source, plugin) + try { + await pluginStorage.save(source, plugin) + publishPlugins(pluginStorage.read()) + } catch (error) { + if (error instanceof CorruptPluginStorageError) pluginStorageCorrupted.value = true + throw error + } +} + +export async function resetPlugins(): Promise { + const reset = await pluginStorage.resetCorruptRecord() publishPlugins(pluginStorage.read()) + if (reset) options.value.activePluginSource = null } if (!Number.isFinite(options.value.rootFontSize) || options.value.rootFontSize <= 0) From 7763879b9896da52d32154b4ef5d170a9c8a078f Mon Sep 17 00:00:00 2001 From: Justineo Date: Tue, 20 Oct 2026 00:32:35 +0800 Subject: [PATCH 059/351] docs(plugins): clarify literal props in MCP exports --- packages/plugins/CHANGELOG.md | 2 +- packages/plugins/README.md | 6 ++++++ packages/plugins/README.zh-Hans.md | 5 +++++ 3 files changed, 12 insertions(+), 1 deletion(-) diff --git a/packages/plugins/CHANGELOG.md b/packages/plugins/CHANGELOG.md index 91c9af6e..f8118900 100644 --- a/packages/plugins/CHANGELOG.md +++ b/packages/plugins/CHANGELOG.md @@ -1,6 +1,6 @@ # Changelog -## 0.6.3 +## Unreleased - Fixed `h()` treating props with a `name` field as a child component. Missing or null props now become an empty object. diff --git a/packages/plugins/README.md b/packages/plugins/README.md index 1d65f7ad..a85fa2aa 100644 --- a/packages/plugins/README.md +++ b/packages/plugins/README.md @@ -154,6 +154,12 @@ export default definePlugin({ TemPad Dev will serialize the returned `DevComponent` into JSX/TSX for display. +For MCP exports, token collection and rewriting apply to `style`, `class`/`className`, and +SVG presentation attributes. Text children and other props remain literal: a custom +`color: 'var(--brand, #fff)'` prop retains its fallback and name and does not add a token entry. +Use `style: { color: 'var(--brand, #fff)' }` when the value represents CSS. Returned strings +and `raw()` content opt into code-level token processing instead. + ### Navigating design nodes Plugins often need to search through the inspected node tree. The following helpers are exported: diff --git a/packages/plugins/README.zh-Hans.md b/packages/plugins/README.zh-Hans.md index d29aef36..89bdebfb 100644 --- a/packages/plugins/README.zh-Hans.md +++ b/packages/plugins/README.zh-Hans.md @@ -148,6 +148,11 @@ export default definePlugin({ TemPad Dev 会将返回的 `DevComponent` 序列化为 JSX/TSX 并展示出来。 +MCP 导出只对 `style`、`class`/`className` 和 SVG 表现属性收集、改写 token。 +文本子节点和其他 prop 保持字面值:例如自定义 `color: 'var(--brand, #fff)'` prop +会保留 fallback 和变量名,也不会添加 token 条目。如果值表示 CSS,请使用 +`style: { color: 'var(--brand, #fff)' }`。返回字符串或 `raw()` 内容则会启用代码级 token 处理。 + ### 遍历设计节点 插件经常需要在被检查的节点树中查找内容。我们导出了以下辅助函数: From f8241e95f44ceeeafb2eee5d33ca8614de34d455 Mon Sep 17 00:00:00 2001 From: Justineo Date: Tue, 20 Oct 2026 00:33:12 +0800 Subject: [PATCH 060/351] perf(extension): rerender text only when token styles change --- docs/extension/mcp-get-code-design.md | 3 + packages/extension/mcp/tools/code/index.ts | 28 +++- .../tests/mcp/tools/code/index.test.ts | 4 +- .../tools/code/text-rerender-pipeline.test.ts | 132 ++++++++++++++++++ 4 files changed, 162 insertions(+), 5 deletions(-) create mode 100644 packages/extension/tests/mcp/tools/code/text-rerender-pipeline.test.ts diff --git a/docs/extension/mcp-get-code-design.md b/docs/extension/mcp-get-code-design.md index 2c3a28e0..6dc368b5 100644 --- a/docs/extension/mcp-get-code-design.md +++ b/docs/extension/mcp-get-code-design.md @@ -114,6 +114,9 @@ A stalled root or unusable shell still fails. - Build a single `tokens` definition map from exact used variable IDs and alias dependencies, preserving all modes and aliases regardless of `resolveTokens`. - When `resolveTokens` is true, resolve code through native `Variable.resolveForConsumer(node)` before final render. Pass the actual node for styles, text runs, and SVG presentation. - The resolve rerender applies to both collected node styles and themeable vector-placeholder root presentation styles so emitted vector color evidence stays in sync with token resolution. +- The first render records its text-run style inputs after inheritance pruning and default resets. + Token resolution reuses those inputs without new native reads; unchanged node, vector, and text + styles retain the first rendered output, including failed-resolution diagnostics. - Serialize the transformed component tree. Apply fallback removal and resolved color simplification at the same styling boundary so CSS-looking design text remains unchanged. diff --git a/packages/extension/mcp/tools/code/index.ts b/packages/extension/mcp/tools/code/index.ts index a33bb7a3..0bd44679 100644 --- a/packages/extension/mcp/tools/code/index.ts +++ b/packages/extension/mcp/tools/code/index.ts @@ -154,6 +154,7 @@ type RenderedMarkup = { serialize: (component: DevComponent) => string lang: CodeLanguage fontStyleVariableIds: Set + textStyleInputs: Array<{ style: Record; node?: SceneNode }> } export async function handleGetCode( @@ -616,6 +617,7 @@ async function finalizeRenderedOutput( ...input, collected, lang: rendered.lang, + textStyleInputs: rendered.textStyleInputs, sourceIndex, resolveNodeIds, tokenMatcher, @@ -676,6 +678,7 @@ async function rerenderResolvedOutput({ resolveNodeIds, tokenMatcher, styleResolver, + textStyleInputs, report, ...input }: { @@ -683,6 +686,7 @@ async function rerenderResolvedOutput({ resolveNodeIds?: Set tokenMatcher?: (value: string) => boolean styleResolver?: StyleVarResolver + textStyleInputs: RenderedMarkup['textStyleInputs'] report: ReturnType['report'] } & PipelineInput & { lang: CodeLanguage @@ -703,7 +707,7 @@ async function rerenderResolvedOutput({ if ( !resolvedEntriesChanged(input.collected.styles, resolvedStyles) && !resolvedEntriesChanged(input.ctx.svgs, resolvedSvgs) && - ![...input.collected.textSegments.values()].some((segments) => segments?.length) + !textStyleInputs.some(({ style, node }) => resolveStyleVars(style, node) !== style) ) { return null } @@ -835,7 +839,19 @@ async function renderMarkup({ let t = clock ? clock() : 0 checkpoint?.() const fontStyleVariableIds = new Set() - const renderContext = { ...ctx, fontStyleVariableIds } + const textStyleInputs: RenderedMarkup['textStyleInputs'] = [] + const renderContext: RenderContext = { + ...ctx, + fontStyleVariableIds, + // Capture the actual run styles while rendering, including inheritance pruning + // and normal-weight resets. Token checks can reuse them without native reads. + resolveStyleVars: + ctx.resolveStyleVars ?? + ((style, node) => { + textStyleInputs.push({ style, node }) + return style + }) + } const rendered = await renderTreeForMode(mode, rootId, tree, renderContext) checkpoint?.() if (!rendered) { @@ -857,7 +873,13 @@ async function renderMarkup({ stampRenderPhase(trace, mode, 'stringify', start) return code } - return { component: transformed, serialize, lang: resolvedLang, fontStyleVariableIds } + return { + component: transformed, + serialize, + lang: resolvedLang, + fontStyleVariableIds, + textStyleInputs + } } function createStringifyOptions(lang: CodeLanguage): { diff --git a/packages/extension/tests/mcp/tools/code/index.test.ts b/packages/extension/tests/mcp/tools/code/index.test.ts index b59f229c..8659e421 100644 --- a/packages/extension/tests/mcp/tools/code/index.test.ts +++ b/packages/extension/tests/mcp/tools/code/index.test.ts @@ -1019,7 +1019,7 @@ describe('mcp/code handleGetCode', () => { expect(mocks.renderTree).toHaveBeenCalledTimes(2) }) - it('rerenders tokens present only in text runs and preserves names for failed resolutions', async () => { + it('reports failed text-run resolutions and retains rewritten names without rerendering', async () => { const text = createSnapshot({ id: 'text', type: 'TEXT' }) const tree = createTree([text]) mockAssetCollection(tree, [], new Set()) @@ -1053,7 +1053,7 @@ describe('mcp/code handleGetCode', () => { ) }) const result = await handleGetCode([text.node], 'jsx', true) - expect(mocks.renderTree).toHaveBeenCalledTimes(2) + expect(mocks.renderTree).toHaveBeenCalledTimes(1) expect(result.code).toContain('var(--renamed)') expect(result.code).not.toContain('12px') expect(result.warnings?.map((warning) => warning.type)).toEqual([ diff --git a/packages/extension/tests/mcp/tools/code/text-rerender-pipeline.test.ts b/packages/extension/tests/mcp/tools/code/text-rerender-pipeline.test.ts new file mode 100644 index 00000000..479dae08 --- /dev/null +++ b/packages/extension/tests/mcp/tools/code/text-rerender-pipeline.test.ts @@ -0,0 +1,132 @@ +import { afterEach, expect, it, vi } from 'vitest' + +import { handleGetCode } from '@/mcp/tools/code' +import { renderTextSegments } from '@/mcp/tools/code/text' + +vi.mock('@/ui/state', () => ({ + activePlugin: { value: undefined }, + options: { value: { cssUnit: 'px', rootFontSize: 16, scale: 1 } } +})) + +vi.mock('@/mcp/tools/code/text/render', async (importOriginal) => { + const original = await importOriginal() + return { ...original, renderTextSegments: vi.fn(original.renderTextSegments) } +}) + +afterEach(() => { + vi.clearAllMocks() + vi.unstubAllGlobals() +}) + +function createText(binding: 'width' | 'fontWeight') { + const value = binding === 'width' ? 24 : 600 + const resolveForConsumer = vi.fn(() => ({ value, resolvedType: 'FLOAT' })) + const variable = { + id: 'shared', + name: 'Shared', + variableCollectionId: 'collection', + resolvedType: 'FLOAT', + scopes: ['WIDTH_HEIGHT'], + valuesByMode: { base: value }, + resolveForConsumer + } + const alias = { type: 'VARIABLE_ALIAS', id: variable.id } + const fill = { type: 'SOLID', color: { r: 0, g: 0, b: 0 } } + const getStyledTextSegments = vi.fn(() => [ + { + characters: 'Text', + start: 0, + end: 4, + fontName: { family: 'Inter', style: 'Regular' }, + fontSize: 16, + fontWeight: binding === 'fontWeight' ? value : 400, + fills: [fill], + ...(binding === 'fontWeight' ? { boundVariables: { fontWeight: alias } } : {}) + } + ]) + const getCSSAsync = vi.fn(async () => ({ + width: binding === 'width' ? 'var(--Shared, 24px)' : '100px', + height: '40px' + })) + vi.stubGlobal('__DEV__', false) + vi.stubGlobal('figma', { + mixed: Symbol('mixed'), + variables: { + getVariableById: () => variable, + getVariableCollectionById: () => ({ + id: 'collection', + name: 'Shared', + defaultModeId: 'base', + modes: [{ modeId: 'base', name: 'Default' }] + }) + }, + getStyleById: () => null + }) + const node = { + id: 'text', + type: 'TEXT', + name: 'Text', + characters: 'Text', + visible: true, + x: 0, + y: 0, + width: binding === 'width' ? value : 100, + height: 40, + relativeTransform: [ + [1, 0, 0], + [0, 1, 0] + ], + absoluteBoundingBox: { x: 0, y: 0, width: 100, height: 40 }, + absoluteRenderBounds: { x: 0, y: 0, width: 100, height: 40 }, + fills: [fill], + strokes: [], + effects: [], + boundVariables: binding === 'width' ? { width: alias } : {}, + getRangeBoundVariable: (_start: number, _end: number, field: string) => + field === binding ? alias : null, + getStyledTextSegments, + getCSSAsync + } as unknown as TextNode + return { node, getCSSAsync, getStyledTextSegments, resolveForConsumer } +} + +it('renders real text once when unresolved tokens do not change any text-run styles', async () => { + const { node, getCSSAsync, getStyledTextSegments } = createText('width') + const result = await handleGetCode([node], 'jsx', false) + expect(result.code).toContain('w-(--Shared)') + expect(result.code).toContain('Text') + expect(result.tokens?.['--Shared']).toEqual({ kind: 'number', value: '24px' }) + expect(renderTextSegments).toHaveBeenCalledTimes(1) + expect(getCSSAsync).toHaveBeenCalledTimes(1) + expect(getStyledTextSegments).toHaveBeenCalledTimes(1) +}) + +it.each([false, true])( + 'still rerenders a token used only by text runs with resolveTokens=%s', + async (resolveTokens) => { + const { node, getCSSAsync, getStyledTextSegments, resolveForConsumer } = + createText('fontWeight') + const result = await handleGetCode([node], 'jsx', resolveTokens) + expect(result.code).toContain( + resolveTokens ? 'font-semibold' : 'font-[weight:calc(var(--Shared)_/_100px_*_100)]' + ) + expect(result.tokens?.['--Shared']).toEqual({ kind: 'number', value: '600px' }) + expect(renderTextSegments).toHaveBeenCalledTimes(2) + expect(getCSSAsync).toHaveBeenCalledTimes(1) + expect(getStyledTextSegments).toHaveBeenCalledTimes(1) + if (resolveTokens) expect(resolveForConsumer).toHaveBeenCalledTimes(1) + } +) + +it('retains real text and reports failed consumer resolution without an unchanged render', async () => { + const { node, resolveForConsumer } = createText('fontWeight') + resolveForConsumer.mockImplementation(() => { + throw new Error('Unavailable consumer') + }) + const result = await handleGetCode([node], 'jsx', true) + expect(result.code).toContain('font-(weight:--Shared)') + expect(result.code).toContain('Text') + expect(result.warnings?.map(({ type }) => type)).toEqual(['token-resolution']) + expect(renderTextSegments).toHaveBeenCalledTimes(1) + expect(resolveForConsumer).toHaveBeenCalledTimes(1) +}) From 44fca9ca3eda51ffe6fec5006baa29b327177a63 Mon Sep 17 00:00:00 2001 From: Justineo Date: Tue, 20 Oct 2026 00:33:24 +0800 Subject: [PATCH 061/351] fix(extension): preserve native HTML properties in Vue output --- .../tests/utils/component.browser.test.ts | 86 +++++++++++++++++++ .../extension/tests/utils/component.test.ts | 21 ++++- packages/extension/utils/component.ts | 59 ++++++++++++- packages/extension/vitest.browser.config.ts | 3 + 4 files changed, 167 insertions(+), 2 deletions(-) diff --git a/packages/extension/tests/utils/component.browser.test.ts b/packages/extension/tests/utils/component.browser.test.ts index 619a2255..b0c3a1c3 100644 --- a/packages/extension/tests/utils/component.browser.test.ts +++ b/packages/extension/tests/utils/component.browser.test.ts @@ -1,7 +1,93 @@ import { expect, it } from 'vitest' +import * as Vue from 'vue' +import { compileTemplate } from 'vue/compiler-sfc' import { stringifyComponent } from '@/utils/component' +it.each([false, true])( + 'keeps native HTML properties functional in compiled Vue markup (editable: %s)', + (editable) => { + const image = 'data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///ywAAAAAAQABAAACAUwAOw== 1x' + const source = stringifyComponent( + { + name: 'div', + props: { contentEditable: editable, spellCheck: editable, draggable: editable }, + children: [ + { + name: 'input', + props: { + autoComplete: 'off', + minLength: 4, + maxLength: 12, + readOnly: true, + inputMode: 'email', + enterKeyHint: 'send' + }, + children: [] + }, + { + name: 'table', + props: {}, + children: [ + { + name: 'tbody', + props: {}, + children: [ + { + name: 'tr', + props: {}, + children: [{ name: 'td', props: { colSpan: 2, rowSpan: 3 }, children: [] }] + } + ] + } + ] + }, + { + name: 'img', + props: { srcSet: image, crossOrigin: 'anonymous', referrerPolicy: 'no-referrer' }, + children: [] + } + ] + }, + 'vue' + ) + const compiled = compileTemplate({ + source, + filename: 'NativeAttributes.vue', + id: 'native-attributes', + compilerOptions: { mode: 'function' } + }) + expect(compiled.errors).toEqual([]) + const app = Vue.createApp({ render: new Function('Vue', compiled.code)(Vue) }) + const host = document.createElement('div') + document.body.append(host) + try { + app.mount(host) + const wrapper = host.firstElementChild as HTMLElement + expect(wrapper.contentEditable).toBe(String(editable)) + expect(wrapper.spellcheck).toBe(editable) + expect(wrapper.draggable).toBe(editable) + const input = host.querySelector('input')! + expect(input.autocomplete).toBe('off') + expect(input.minLength).toBe(4) + expect(input.maxLength).toBe(12) + expect(input.readOnly).toBe(true) + expect(input.inputMode).toBe('email') + expect(input.enterKeyHint).toBe('send') + const cell = host.querySelector('td')! + expect(cell.colSpan).toBe(2) + expect(cell.rowSpan).toBe(3) + const img = host.querySelector('img')! + expect(img.srcset).toBe(image) + expect(img.crossOrigin).toBe('anonymous') + expect(img.referrerPolicy).toBe('no-referrer') + } finally { + app.unmount() + host.remove() + } + } +) + it('keeps native readonly, maxlength and SVG link attributes functional in Vue markup', () => { const host = document.createElement('div') document.body.append(host) diff --git a/packages/extension/tests/utils/component.test.ts b/packages/extension/tests/utils/component.test.ts index 909fe87d..cfbc6b7c 100644 --- a/packages/extension/tests/utils/component.test.ts +++ b/packages/extension/tests/utils/component.test.ts @@ -29,20 +29,39 @@ function createNode( describe('utils/component', () => { it('normalizes native HTML property names without changing component props or JSX', () => { - const props = { readOnly: true, maxLength: 12 } + const props = { + readOnly: true, + maxLength: 12, + autoComplete: 'off', + contentEditable: true, + spellCheck: false, + ariaLabel: 'Name', + dataTestId: 'input' + } const input = { name: 'input', props, children: [] } const vue = stringifyComponent(input, 'vue') expect(vue).toContain('\n readonly\n') expect(vue).toContain(':maxlength="12"') + expect(vue).toContain('autocomplete="off"') + expect(vue).toContain(':contenteditable="true"') + expect(vue).toContain(':spellcheck="false"') + expect(vue).toContain('aria-label="Name"') + expect(vue).toContain('data-test-id="input"') for (const name of ['InputField', 'custom-input']) { const custom = stringifyComponent({ name, props, children: [] }, 'vue') expect(custom).toContain('\n read-only\n') expect(custom).toContain(':max-length="12"') + expect(custom).toContain('auto-complete="off"') + expect(custom).toContain('\n content-editable\n') + expect(custom).toContain(':spell-check="false"') } const jsx = stringifyComponent(input, 'jsx') expect(jsx).toContain('\n readOnly\n') expect(jsx).toContain('maxLength={12}') + expect(jsx).toContain('autoComplete="off"') + expect(jsx).toContain('\n contentEditable\n') + expect(jsx).toContain('spellCheck={false}') }) it('normalizes native SVG namespaces while respecting foreignObject and component props', () => { diff --git a/packages/extension/utils/component.ts b/packages/extension/utils/component.ts index 95517c4c..733ce60e 100644 --- a/packages/extension/utils/component.ts +++ b/packages/extension/utils/component.ts @@ -314,6 +314,60 @@ const VOID_HTML_TAGS = new Set([ ]) const EVENT_HANDLER_RE = /^on[A-Z]/ +// Native HTML names are case-insensitive, but these DOM/JSX properties have no hyphen. +const HTML_CAMEL_CASE_ATTRIBUTES = new Set([ + 'accessKey', + 'allowFullScreen', + 'autoCapitalize', + 'autoComplete', + 'autoFocus', + 'autoPlay', + 'cellPadding', + 'cellSpacing', + 'charSet', + 'colSpan', + 'contentEditable', + 'contextMenu', + 'controlsList', + 'crossOrigin', + 'dateTime', + 'dirName', + 'disablePictureInPicture', + 'disableRemotePlayback', + 'encType', + 'enterKeyHint', + 'fetchPriority', + 'formAction', + 'formEncType', + 'formMethod', + 'formNoValidate', + 'formTarget', + 'frameBorder', + 'hrefLang', + 'inputMode', + 'isMap', + 'itemID', + 'itemProp', + 'itemRef', + 'itemScope', + 'itemType', + 'marginHeight', + 'marginWidth', + 'maxLength', + 'minLength', + 'noModule', + 'noValidate', + 'playsInline', + 'readOnly', + 'referrerPolicy', + 'rowSpan', + 'spellCheck', + 'srcDoc', + 'srcLang', + 'srcSet', + 'useMap' +]) +const HTML_ENUMERATED_BOOLEAN_ATTRIBUTES = new Set(['contenteditable', 'draggable', 'spellcheck']) const SVG_CASE_SENSITIVE_ATTRIBUTES = new Set([ 'viewBox', 'preserveAspectRatio', @@ -393,7 +447,7 @@ function getVueAttributeName(key: string, inSvg: boolean, isCustomTag: boolean): if (!isCustomTag) { if (inSvg && key === 'xlinkHref') return 'xlink:href' - if (!inSvg && (key === 'readOnly' || key === 'maxLength')) return key.toLowerCase() + if (!inSvg && HTML_CAMEL_CASE_ATTRIBUTES.has(key)) return key.toLowerCase() } return inSvg && SVG_CASE_SENSITIVE_ATTRIBUTES.has(key) ? key : camelToKebab(key) @@ -420,6 +474,9 @@ function stringifyVueComponent( } if (typeof value === 'boolean') { + if (!inSvg && !isCustomTag && HTML_ENUMERATED_BOOLEAN_ATTRIBUTES.has(name)) { + return `:${name}="${value}"` + } return value ? name : `:${name}="false"` } diff --git a/packages/extension/vitest.browser.config.ts b/packages/extension/vitest.browser.config.ts index bf6e7d06..35cc5af6 100644 --- a/packages/extension/vitest.browser.config.ts +++ b/packages/extension/vitest.browser.config.ts @@ -8,6 +8,9 @@ const rootDir = fileURLToPath(new URL('.', import.meta.url)) export default defineConfig({ plugins: [vue()], + optimizeDeps: { + include: ['vue/compiler-sfc'] + }, resolve: { conditions: ['source', 'module', 'browser', 'development|production'], alias: { From e2293f72a98bed3f3f2ed44a7491fd72993a9644 Mon Sep 17 00:00:00 2001 From: Justineo Date: Tue, 20 Oct 2026 00:33:44 +0800 Subject: [PATCH 062/351] fix(extension): retain focus while retrying task cleanup --- packages/extension/components/Button.vue | 4 ++-- .../extension/components/DesignTaskStatus.vue | 8 ++++---- .../design-task-status.browser.test.ts | 16 ++++++++++++++++ 3 files changed, 22 insertions(+), 6 deletions(-) diff --git a/packages/extension/components/Button.vue b/packages/extension/components/Button.vue index 4b8900c2..85018984 100644 --- a/packages/extension/components/Button.vue +++ b/packages/extension/components/Button.vue @@ -70,13 +70,13 @@ withDefaults( outline-color: var(--color-border-onbrand-strong); } -.tp-action-button-secondary:disabled { +.tp-action-button-secondary:is(:disabled, [aria-disabled='true']) { background: transparent; color: var(--color-text-disabled); outline-color: var(--color-border-disabled); } -.tp-action-button-primary:disabled { +.tp-action-button-primary:is(:disabled, [aria-disabled='true']) { background: var(--color-bg-disabled); color: var(--color-text-ondisabled); } diff --git a/packages/extension/components/DesignTaskStatus.vue b/packages/extension/components/DesignTaskStatus.vue index c300e747..c947031d 100644 --- a/packages/extension/components/DesignTaskStatus.vue +++ b/packages/extension/components/DesignTaskStatus.vue @@ -75,6 +75,7 @@ const dismissed = shallowRef(false) let exitTimer: ReturnType | undefined async function done(confirmed = false) { + if (finishing.value) return if ( !confirmed && props.task?.status === 'completed' && @@ -84,10 +85,8 @@ async function done(confirmed = false) { return } const taskId = props.task?.taskId - if (!taskId || stoppable.value || dismissBlocked.value || exitPhase.value || finishing.value) - return + if (!taskId || stoppable.value || dismissBlocked.value || exitPhase.value) return finishing.value = true - cancelledCleanupFailed.value = false let cleared = false try { const completed = props.task?.status === 'completed' @@ -517,7 +516,8 @@ onScopeDispose(() => {