fix: complete acronym casing follow-ups

- Cover UI identifiers in the acronym guardrail
- Preserve FIG thumbnail and metadata values through archive parsing
- Use Vue-compatible acronym prop attributes
This commit is contained in:
Danila Poyarkov 2026-08-10 16:38:17 +03:00
parent 7f91594d0c
commit 55b368dc4a
15 changed files with 76 additions and 66 deletions

View file

@ -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',

View file

@ -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<Record<string, Uint8Array>>): 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. */

View file

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

View file

@ -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<void> {
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<void> {
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<void> {
...job,
nextAttemptAt: Number.MAX_SAFE_INTEGER
})
setSyncUi('error', message)
setSyncUI('error', message)
return
}
@ -191,7 +191,7 @@ async function pumpOnce(): Promise<void> {
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<void> {
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<void> {
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<void> {
await getLocalCanvasStore().clearAll()
await getOutbox().clear()
setPendingSyncCount(0)
setSyncUi('idle')
setSyncUI('idle')
}

View file

@ -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'

View file

@ -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<SyncUiState>('idle')
export const syncUiDetail = ref<string | null>(null)
export const syncUIState = ref<SyncUIState>('idle')
export const syncUIDetail = ref<string | null>(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) {

View file

@ -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(

View file

@ -54,7 +54,7 @@ const toolShortcuts: Record<Tool, string> = {
}
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"

View file

@ -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"
/>

View file

@ -44,8 +44,8 @@ async function clearUnsplashKey(): Promise<void> {
: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<void> {
: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"
/>

View file

@ -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"
/>

View file

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

View file

@ -112,7 +112,7 @@ export function dynamicClassDiagnostics(sourceRel: string, content: string) {
return diagnostics
}
function containsUiHookCall(node: unknown, visited = new Set<ExpressionNode>()): boolean {
function containsUIHookCall(node: unknown, visited = new Set<ExpressionNode>()): boolean {
if (!isExpressionNode(node) || visited.has(node)) return false
visited.add(node)
if (
@ -127,8 +127,8 @@ function containsUiHookCall(node: unknown, visited = new Set<ExpressionNode>()):
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
)

View file

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

View file

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