diff --git a/lint/plugin.js b/lint/plugin.js index 3c5333d7d..abfdc110b 100644 --- a/lint/plugin.js +++ b/lint/plugin.js @@ -2110,7 +2110,7 @@ const noMixedCaseAcronymIdentifiers = { } }, create(context) { - const canonicalAcronym = /(?:Acp|Ai|Api|Cli|Cors|Css|Html|Ime|Json|Jsx|Mcp|Pdf|Png|Rgb|Rpc|Rtl|Svg|Url|Uri|Xml)/g + const canonicalAcronym = /(?:Acp|Ai|Api|Cli|Cors|Css|Html|Ime|Json|Jsx|Mcp|Pdf|Png|Rgb|Rpc|Rtl|Svg|Ui|Url|Uri|Xml)/g const ignoredImports = new Set([ '@agentclientprotocol/sdk', '@tauri-apps/plugin-clipboard-manager', diff --git a/packages/fig/src/archive.ts b/packages/fig/src/archive.ts index c52a96fe4..cc5e7dc39 100644 --- a/packages/fig/src/archive.ts +++ b/packages/fig/src/archive.ts @@ -25,6 +25,8 @@ export interface FigParseResult { figKiwiVersion: number /** Deflated Kiwi schema bytes from the original file, retained for round-trip fidelity. */ figSchemaDeflated: Uint8Array + thumbnailPNG: Uint8Array | null + metaJSON: string | null } function isLikelyAsset(name: string): boolean { @@ -46,12 +48,7 @@ function findCanvasData(entries: Partial>): Uint8Arra /** Parse a complete zipped `.fig` file into its Figma protocol payload and binary resources. */ export function parseFigBuffer(buffer: ArrayBuffer): FigParseResult { - const archive = unzipSync(new Uint8Array(buffer), { - filter: (file) => - file.name === 'canvas.fig' || - file.name === 'canvas' || - (file.name.startsWith('images/') && file.name !== 'images/') - }) + const archive = unzipSync(new Uint8Array(buffer)) const canvasData = findCanvasData(archive) if (!canvasData) { throw new Error( @@ -60,11 +57,17 @@ export function parseFigBuffer(buffer: ArrayBuffer): FigParseResult { } const decoded = decodeFigKiwiCanvas(canvasData) + const metaBytes = archive['meta.json'] const images = Object.entries(archive) .filter(([name]) => name.startsWith('images/') && name !== 'images/') .map(([name, data]) => [name.slice('images/'.length), data] as [string, Uint8Array]) - return { ...decoded, images } + return { + ...decoded, + images, + thumbnailPNG: archive['thumbnail.png'] ?? null, + metaJSON: Object.hasOwn(archive, 'meta.json') ? new TextDecoder().decode(metaBytes) : null + } } /** Assemble a complete zipped `.fig` archive from an encoded Kiwi message and resources. */ diff --git a/packages/fig/tests/index.test.ts b/packages/fig/tests/index.test.ts index 4ac85f8ee..2a118f1f4 100644 --- a/packages/fig/tests/index.test.ts +++ b/packages/fig/tests/index.test.ts @@ -41,6 +41,8 @@ describe('@open-pencil/fig package API', () => { }) it('parses complete .fig archives and image resources', () => { + const thumbnailPNG = new Uint8Array([1]) + const metaJSON = '{}' const bytes = writeFigArchive({ schemaDeflated: deflateSync(getSchemaBytes()), kiwiData: encodeMessage( @@ -53,8 +55,8 @@ describe('@open-pencil/fig package API', () => { } ]) ), - thumbnailPNG: new Uint8Array([1]), - metaJSON: '{}', + thumbnailPNG, + metaJSON, images: [{ name: 'images/hash', data: new Uint8Array([9, 8, 7]) }] }) const parsed = parseFigBuffer(bytes.buffer as ArrayBuffer) @@ -62,6 +64,8 @@ describe('@open-pencil/fig package API', () => { expect(parsed.nodeChanges).toHaveLength(1) expect(parsed.nodeChanges[0]?.type).toBe('DOCUMENT') expect(parsed.images).toEqual([['hash', new Uint8Array([9, 8, 7])]]) + expect(parsed.thumbnailPNG).toEqual(thumbnailPNG) + expect(parsed.metaJSON).toBe(metaJSON) }) it('rejects invalid fig-kiwi containers', () => { diff --git a/src/app/storage/sync/engine.ts b/src/app/storage/sync/engine.ts index 24ecd2b26..9ff0c629e 100644 --- a/src/app/storage/sync/engine.ts +++ b/src/app/storage/sync/engine.ts @@ -11,7 +11,7 @@ import { evictLocalFigCache } from '@/app/storage/cache-eviction' import { getLocalCanvasStore } from '@/app/storage/local-store' import { getOutbox } from '@/app/storage/sync/outbox' import { setUploadProgress } from '@/app/storage/sync/progress' -import { setPendingSyncCount, setSyncUi } from '@/app/storage/sync/status' +import { setPendingSyncCount, setSyncUI } from '@/app/storage/sync/status' import type { OutboxJob } from '@/app/storage/sync/types' const MAX_ATTEMPTS = 8 @@ -141,17 +141,17 @@ async function pumpOnce(): Promise { setPendingSyncCount(jobs.length) if (jobs.length === 0) { - if (isOnline()) setSyncUi('idle') + if (isOnline()) setSyncUI('idle') return } if (!isOnline()) { - setSyncUi('offline') + setSyncUI('offline') scheduleWake(5000) return } - setSyncUi('syncing') + setSyncUI('syncing') const now = Date.now() // Single-flight globally for simplicity (large figs) const job = jobs.find((j) => j.nextAttemptAt <= now) @@ -166,7 +166,7 @@ async function pumpOnce(): Promise { await outbox.remove(job.id) const remaining = await outbox.list() setPendingSyncCount(remaining.length) - if (remaining.length === 0) setSyncUi('idle') + if (remaining.length === 0) setSyncUI('idle') else scheduleWake(50) } catch (error) { const message = error instanceof Error ? error.message : String(error) @@ -175,7 +175,7 @@ async function pumpOnce(): Promise { ...job, nextAttemptAt: Number.MAX_SAFE_INTEGER }) - setSyncUi('error', message) + setSyncUI('error', message) return } @@ -191,7 +191,7 @@ async function pumpOnce(): Promise { syncStatus: 'error', lastSyncError: message }) - setSyncUi('error', message.slice(0, 120)) + setSyncUI('error', message.slice(0, 120)) } else { // Keep a record without touching syncStatus so the stale remote // thumbnail is at least diagnosable. @@ -202,7 +202,7 @@ async function pumpOnce(): Promise { const remaining = await outbox.list() setPendingSyncCount(remaining.length) if (remaining.length > 0) scheduleWake(1000) - else setSyncUi('idle') + else setSyncUI('idle') } else { // Never discard a document mutation. Keep it durable until the user // repairs credentials/permissions and explicitly wakes synchronization. @@ -247,11 +247,11 @@ function ensureOnlineListeners() { if (onlineBound || !IS_BROWSER) return onlineBound = true window.addEventListener('online', () => { - setSyncUi('syncing') + setSyncUI('syncing') void kickSyncEngine() }) window.addEventListener('offline', () => { - setSyncUi('offline') + setSyncUI('offline') }) } @@ -307,7 +307,7 @@ export async function resumeStorageSync(): Promise { const jobs = await outbox.list() const now = Date.now() await Promise.all(jobs.map((job) => outbox.update({ ...job, nextAttemptAt: now }))) - if (jobs.length > 0) setSyncUi('syncing') + if (jobs.length > 0) setSyncUI('syncing') void kickSyncEngine() } @@ -316,5 +316,5 @@ export async function clearStorageLocalMirror(): Promise { await getLocalCanvasStore().clearAll() await getOutbox().clear() setPendingSyncCount(0) - setSyncUi('idle') + setSyncUI('idle') } diff --git a/src/app/storage/sync/index.ts b/src/app/storage/sync/index.ts index dc919be23..5c04d19cf 100644 --- a/src/app/storage/sync/index.ts +++ b/src/app/storage/sync/index.ts @@ -17,15 +17,15 @@ export { setUploadProgress, uploadProgressByCanvas } from './progress' export { pendingSyncCount, setPendingSyncCount, - setSyncUi, + setSyncUI, syncStatusLabel, - syncUiDetail, - syncUiState + syncUIDetail, + syncUIState } from './status' export { makeJobId, supersedePutCanvasJobs, type OutboxJob, type OutboxJobType, - type SyncUiState + type SyncUIState } from './types' diff --git a/src/app/storage/sync/status.ts b/src/app/storage/sync/status.ts index 4dc0ea567..12b685634 100644 --- a/src/app/storage/sync/status.ts +++ b/src/app/storage/sync/status.ts @@ -1,28 +1,28 @@ import { computed, ref } from 'vue' -import type { SyncUiState } from '@/app/storage/sync/types' +import type { SyncUIState } from '@/app/storage/sync/types' /** Global subtle sync status for UI chips. */ -export const syncUiState = ref('idle') -export const syncUiDetail = ref(null) +export const syncUIState = ref('idle') +export const syncUIDetail = ref(null) export const pendingSyncCount = ref(0) export const syncStatusLabel = computed(() => { - switch (syncUiState.value) { + switch (syncUIState.value) { case 'syncing': - return syncUiDetail.value ?? 'Syncing…' + return syncUIDetail.value ?? 'Syncing…' case 'offline': return 'Offline · will sync' case 'error': - return syncUiDetail.value ?? 'Sync failed' + return syncUIDetail.value ?? 'Sync failed' default: return null } }) -export function setSyncUi(state: SyncUiState, detail: string | null = null) { - syncUiState.value = state - syncUiDetail.value = detail +export function setSyncUI(state: SyncUIState, detail: string | null = null) { + syncUIState.value = state + syncUIDetail.value = detail } export function setPendingSyncCount(count: number) { diff --git a/src/app/storage/sync/types.ts b/src/app/storage/sync/types.ts index 9b38953cc..c1ffad814 100644 --- a/src/app/storage/sync/types.ts +++ b/src/app/storage/sync/types.ts @@ -11,7 +11,7 @@ export type OutboxJob = { nextAttemptAt: number } -export type SyncUiState = 'idle' | 'syncing' | 'offline' | 'error' +export type SyncUIState = 'idle' | 'syncing' | 'offline' | 'error' /** Pure helper: drop older putCanvas jobs for same canvas when a newer revision is enqueued. */ export function supersedePutCanvasJobs( diff --git a/src/components/Toolbar/Toolbar.vue b/src/components/Toolbar/Toolbar.vue index e3949e0e6..31059ddf4 100644 --- a/src/components/Toolbar/Toolbar.vue +++ b/src/components/Toolbar/Toolbar.vue @@ -54,7 +54,7 @@ const toolShortcuts: Record = { } const flyoutMenuCls = useMenuUI({ content: 'min-w-32' }) -const toolbarUi = { flyoutContent: flyoutMenuCls.content } +const toolbarUI = { flyoutContent: flyoutMenuCls.content } const { editActions, arrangeActions } = useToolbarActions({ store, getCommand, menu }) const { mobileCategory, slideDirection, hasPrev, hasNext, goPrev, goNext } = useToolbarState() @@ -75,7 +75,7 @@ function onActionTap(item: ToolbarActionItem) { :tool-icons="toolIcons" :tool-labels="toolLabels" :tool-shortcuts="toolShortcuts" - :ui="toolbarUi" + :ui="toolbarUI" @set-tool="actions.setTool" /> @@ -87,7 +87,7 @@ function onActionTap(item: ToolbarActionItem) { :tool-icons="toolIcons" :tool-labels="toolLabels" :tool-shortcuts="toolShortcuts" - :ui="toolbarUi" + :ui="toolbarUI" :mobile-category="mobileCategory" :slide-direction="slideDirection" :has-prev="hasPrev" diff --git a/src/components/settings/models/ProfileEditor.vue b/src/components/settings/models/ProfileEditor.vue index 6c69e274b..ba0b9d008 100644 --- a/src/components/settings/models/ProfileEditor.vue +++ b/src/components/settings/models/ProfileEditor.vue @@ -376,8 +376,8 @@ void refreshKeyStatus() :saved="hasExistingKey" kind="api" :placeholder="hasExistingKey ? dialogs.keySavedReplace : providerDef.keyPlaceholder" - :key-url="providerDef.keyURL" - :key-url-label="dialogs.getAPIKeyGeneric" + :key-u-r-l="providerDef.keyURL" + :key-u-r-l-label="dialogs.getAPIKeyGeneric" @clear="clearKey" /> diff --git a/src/components/settings/provider/StockPhotoKeysSection.vue b/src/components/settings/provider/StockPhotoKeysSection.vue index ed0e87039..f70536a68 100644 --- a/src/components/settings/provider/StockPhotoKeysSection.vue +++ b/src/components/settings/provider/StockPhotoKeysSection.vue @@ -44,8 +44,8 @@ async function clearUnsplashKey(): Promise { :saved="hasExistingPexelsKey" kind="pexels" :placeholder="hasExistingPexelsKey ? dialogs.keySavedReplace : dialogs.stockPhotoToolOptional" - key-url="https://www.pexels.com/api/" - :key-url-label="dialogs.getPexelsAPIKey" + key-u-r-l="https://www.pexels.com/api/" + :key-u-r-l-label="dialogs.getPexelsAPIKey" @clear="clearPexelsKey" @change="savePexelsKey" /> @@ -58,8 +58,8 @@ async function clearUnsplashKey(): Promise { :placeholder=" hasExistingUnsplashKey ? dialogs.keySavedReplace : dialogs.pexelsAlternativeOptional " - key-url="https://unsplash.com/oauth/applications" - :key-url-label="dialogs.getUnsplashAccessKey" + key-u-r-l="https://unsplash.com/oauth/applications" + :key-u-r-l-label="dialogs.getUnsplashAccessKey" @clear="clearUnsplashKey" @change="saveUnsplashKey" /> diff --git a/src/components/settings/vectorize/VectorizeSettingsSection.vue b/src/components/settings/vectorize/VectorizeSettingsSection.vue index cda01dde0..00164c6c2 100644 --- a/src/components/settings/vectorize/VectorizeSettingsSection.vue +++ b/src/components/settings/vectorize/VectorizeSettingsSection.vue @@ -72,8 +72,8 @@ onMounted(() => void refreshStatus()) :saved="keyStatus === 'configured'" kind="api" :placeholder="keyStatus === 'configured' ? dialogs.keySavedReplace : provider.keyPlaceholder" - :key-url="provider.keyURL" - :key-url-label="dialogs.getAPIKeyGeneric" + :key-u-r-l="provider.keyURL" + :key-u-r-l-label="dialogs.getAPIKeyGeneric" @change="saveCredential" @clear="clearCredential" /> diff --git a/tests/engine/io/fig/heavy/fixtures.test.ts b/tests/engine/io/fig/heavy/fixtures.test.ts index 5166614a1..6e55fd18e 100644 --- a/tests/engine/io/fig/heavy/fixtures.test.ts +++ b/tests/engine/io/fig/heavy/fixtures.test.ts @@ -12,13 +12,13 @@ heavy('parse heavy .fig files', () => { let material3: SceneGraph let nuxtui: SceneGraph let material3Nodes: SceneNode[] - let nuxtUiNodes: SceneNode[] + let nuxtUINodes: SceneNode[] beforeAll(async () => { material3 = await parseFixture('material3.fig', { populate: 'none' }) nuxtui = await parseFixture('nuxtui.fig', { populate: 'none' }) material3Nodes = collectAllNodes(material3) - nuxtUiNodes = collectAllNodes(nuxtui) + nuxtUINodes = collectAllNodes(nuxtui) }) test('material3.fig parses with pages and nodes', () => { @@ -30,7 +30,7 @@ heavy('parse heavy .fig files', () => { test('nuxtui.fig parses with pages and nodes', () => { expect(nuxtui).toBeInstanceOf(SceneGraph) expect(nuxtui.getPages().length).toBeGreaterThan(0) - expect(nuxtUiNodes.length).toBeGreaterThan(0) + expect(nuxtUINodes.length).toBeGreaterThan(0) }) test('material3: contains COMPONENT nodes', () => { @@ -43,7 +43,7 @@ heavy('parse heavy .fig files', () => { }) test('nuxtui: no unmapped node types', () => { - const invalid = nuxtUiNodes.filter((n) => !VALID_NODE_TYPES.has(n.type)) + const invalid = nuxtUINodes.filter((n) => !VALID_NODE_TYPES.has(n.type)) expect(invalid.map((n) => `${n.name}: ${n.type}`)).toEqual([]) }) @@ -66,7 +66,7 @@ heavy('parse heavy .fig files', () => { }) test('nuxtui: fills have valid colors', () => { - for (const n of nuxtUiNodes) { + for (const n of nuxtUINodes) { for (const fill of n.fills) { if (fill.type === 'SOLID') { expect(fill.color.r).toBeGreaterThanOrEqual(0) diff --git a/tools/architecture/src/steiger-rules/dynamic-tailwind-classes.ts b/tools/architecture/src/steiger-rules/dynamic-tailwind-classes.ts index 4d82bee92..d46451c91 100644 --- a/tools/architecture/src/steiger-rules/dynamic-tailwind-classes.ts +++ b/tools/architecture/src/steiger-rules/dynamic-tailwind-classes.ts @@ -112,7 +112,7 @@ export function dynamicClassDiagnostics(sourceRel: string, content: string) { return diagnostics } -function containsUiHookCall(node: unknown, visited = new Set()): boolean { +function containsUIHookCall(node: unknown, visited = new Set()): boolean { if (!isExpressionNode(node) || visited.has(node)) return false visited.add(node) if ( @@ -127,8 +127,8 @@ function containsUiHookCall(node: unknown, visited = new Set()): return Object.entries(node).some(([key, value]) => { if (key === 'loc' || key === 'start' || key === 'end') return false return Array.isArray(value) - ? value.some((child) => containsUiHookCall(child, visited)) - : containsUiHookCall(value, visited) + ? value.some((child) => containsUIHookCall(child, visited)) + : containsUIHookCall(value, visited) }) } @@ -145,7 +145,7 @@ export function vueTemplateGuardrailDiagnostics(sourceRel: string, content: stri column: node.loc?.start?.column }) } - if (node.type === VUE_DIRECTIVE_NODE && containsUiHookCall(node.exp?.ast)) { + if (node.type === VUE_DIRECTIVE_NODE && containsUIHookCall(node.exp?.ast)) { diagnostics.push({ message: 'Resolve use*UI() hooks once in script setup, not inside template expressions.', line: node.loc?.start?.line, @@ -156,7 +156,7 @@ export function vueTemplateGuardrailDiagnostics(sourceRel: string, content: stri return diagnostics } -export const noVueTemplateUiHooksOrSVG = createTextRule( +export const noVueTemplateUIHooksOrSVG = createTextRule( 'open-pencil/no-vue-template-ui-hooks-or-svg', vueTemplateGuardrailDiagnostics ) diff --git a/tools/architecture/src/steiger-rules/index.ts b/tools/architecture/src/steiger-rules/index.ts index 4f3bb67a5..6a42fea0f 100644 --- a/tools/architecture/src/steiger-rules/index.ts +++ b/tools/architecture/src/steiger-rules/index.ts @@ -5,7 +5,7 @@ import { parse as parseVueSfc } from 'vue/compiler-sfc' import { noCrossPackageReexportShims } from './cross-package-reexport-shims.ts' import { noDynamicTailwindStateClasses, - noVueTemplateUiHooksOrSVG + noVueTemplateUIHooksOrSVG } from './dynamic-tailwind-classes.ts' import { collectFolders, @@ -305,7 +305,7 @@ const noComponentsImportViews = createImportRule( } ) -const noNonUiImportsInSharedUi = createImportRule( +const noNonUIImportsInSharedUI = createImportRule( 'open-pencil/no-non-ui-imports-in-shared-ui', (sourceRel, _specifier, resolved) => { if (!sourceRel.startsWith('src/components/ui/')) return null @@ -326,7 +326,7 @@ const noViewsImportedOutsideEntry = createImportRule( } ) -const noAppImportsInSharedUi = createImportRule( +const noAppImportsInSharedUI = createImportRule( 'open-pencil/no-app-imports-in-shared-ui', (sourceRel, _specifier, resolved) => { if (!sourceRel.startsWith('src/components/ui/')) return null @@ -476,7 +476,7 @@ const noShortcutTextInLabels = createTextRule( } ) -const noUiImportsInCore = createImportRule( +const noUIImportsInCore = createImportRule( 'open-pencil/no-ui-imports-in-core', (sourceRel, specifier) => { if (!sourceRel.startsWith('packages/core/src/')) return null @@ -514,15 +514,15 @@ export const openPencilArchitecturePlugin = { noAppImportsComponentsOrViews, noComponentsImportViews, noViewsImportedOutsideEntry, - noNonUiImportsInSharedUi, - noAppImportsInSharedUi, + noNonUIImportsInSharedUI, + noAppImportsInSharedUI, noPropertyPanelInternalsOutsidePanel, noProductionTestIdsInSharedLayers, noDynamicTailwindStateClasses, - noVueTemplateUiHooksOrSVG, + noVueTemplateUIHooksOrSVG, noNativeTitleAttributesInVue, noShortcutTextInLabels, noHardcodedMacOSShortcutGlyphs, - noUiImportsInCore + noUIImportsInCore ] } diff --git a/tools/architecture/tests/acronym-casing.test.ts b/tools/architecture/tests/acronym-casing.test.ts index 2043c225e..07fdac5c9 100644 --- a/tools/architecture/tests/acronym-casing.test.ts +++ b/tools/architecture/tests/acronym-casing.test.ts @@ -35,10 +35,13 @@ afterEach(() => { describe('acronym identifier casing', () => { test('rejects mixed-case first-party acronym identifiers', () => { - const result = lint('export class CloudCorsError {}\nexport function sendRpc() {}') + const result = lint( + 'export class CloudCorsError {}\nexport function sendRpc() {}\nexport type FontPickerUi = {}' + ) expect(result.exitCode).toBe(1) expect(result.stdout.toString()).toContain('CloudCorsError') expect(result.stdout.toString()).toContain('sendRpc') + expect(result.stdout.toString()).toContain('FontPickerUi') }) test('accepts canonical first-party acronym identifiers', () => {