diff --git a/lint/plugin.js b/lint/plugin.js index 0bd0a88ac..28019b98d 100644 --- a/lint/plugin.js +++ b/lint/plugin.js @@ -182,7 +182,13 @@ function vueTemplateAst(source, filename) { } function isVueSourceFile(file) { - return file.endsWith('.vue') && (file.includes('/src/') || file.includes('/packages/vue/src/')) + return ( + file.endsWith('.vue') && + (file.startsWith('src/') || + file.includes('/src/') || + file.startsWith('packages/vue/src/') || + file.includes('/packages/vue/src/')) + ) } function sourceLineCount(source) { @@ -272,8 +278,7 @@ const noVueStyleBlocks = { }, create(context) { const file = normalizedFilename(context) - if (!file.endsWith('.vue')) return {} - if (!file.includes('/src/') && !file.includes('/packages/vue/src/')) return {} + if (!isVueSourceFile(file)) return {} return { Program(node) { @@ -297,8 +302,7 @@ const noNativeTitleAttributesInVue = { }, create(context) { const file = normalizedFilename(context) - if (!file.endsWith('.vue')) return {} - if (!file.includes('/src/') && !file.includes('/packages/vue/src/')) return {} + if (!isVueSourceFile(file)) return {} return { Program(node) { @@ -329,8 +333,7 @@ const noHardcodedTipLabelsInVue = { }, create(context) { const file = normalizedFilename(context) - if (!file.endsWith('.vue')) return {} - if (!file.includes('/src/') && !file.includes('/packages/vue/src/')) return {} + if (!isVueSourceFile(file)) return {} return { Program(node) { diff --git a/src/components/properties/ExportScaleInput.vue b/src/components/properties/ExportScaleInput.vue index 9f648a6fd..c9bf99412 100644 --- a/src/components/properties/ExportScaleInput.vue +++ b/src/components/properties/ExportScaleInput.vue @@ -12,6 +12,7 @@ import { vTestId, type TestIdProps } from '@open-pencil/vue' import { useInputUI } from '@/components/ui/input' import { menuItem, useMenuUI } from '@/components/ui/menu' +import Tip from '@/components/ui/Tip.vue' interface ExportScaleInputProps extends TestIdProps { presets: readonly number[] @@ -35,7 +36,10 @@ const text = ref('') // Keep the editable text in sync with the committed scale (e.g. 1.5 -> "1.5x"). watch(modelValue, (value) => (text.value = `${value}x`), { immediate: true }) -const inputClass = useInputUI({ size: 'sm', ui: { base: 'min-w-0 flex-1' } }).base +const inputClass = useInputUI({ + size: 'sm', + ui: { base: 'min-w-0 flex-1 rounded-none border-0 bg-transparent focus:border-transparent' } +}).base const menuCls = useMenuUI({ content: 'min-w-[7rem]' }) const itemCls = menuItem({ justify: 'between' }) @@ -58,46 +62,50 @@ function isActive(scale: number) { - - - - - - - - - - - + + + + + - {{ scale }}x - - - - - - + + + + + + + {{ scale }}x + + + + + + + diff --git a/src/components/ui/AppSelect.vue b/src/components/ui/AppSelect.vue index 133417dfb..0ab42df30 100644 --- a/src/components/ui/AppSelect.vue +++ b/src/components/ui/AppSelect.vue @@ -49,7 +49,7 @@ const indicator = ui?.indicator ?? 'absolute left-1.5 inline-flex items-center j - + diff --git a/steiger.config.ts b/steiger.config.ts index 2f7b12ce8..5b2ead48b 100644 --- a/steiger.config.ts +++ b/steiger.config.ts @@ -38,6 +38,7 @@ export default defineConfig([ 'open-pencil/no-non-ui-imports-in-shared-ui': 'error', 'open-pencil/no-app-imports-in-shared-ui': 'error', 'open-pencil/no-property-panel-internals-outside-panel': 'error', + 'open-pencil/no-native-title-attributes-in-vue': 'error', 'open-pencil/no-ui-imports-in-core': 'error', 'open-pencil/scripts-are-entrypoint-shims': 'error', 'open-pencil/strict-tools-layout': 'error' diff --git a/tools/architecture/src/steiger-rules/index.ts b/tools/architecture/src/steiger-rules/index.ts index e519d0582..9b0f03f44 100644 --- a/tools/architecture/src/steiger-rules/index.ts +++ b/tools/architecture/src/steiger-rules/index.ts @@ -1,5 +1,7 @@ import path from 'node:path' +import { parse as parseVueSfc } from 'vue/compiler-sfc' + import { collectFolders, createFileRule, @@ -360,6 +362,57 @@ const noHardcodedMacOSShortcutGlyphs = createTextRule( } ) +const VUE_ATTRIBUTE_NODE = 6 +const VUE_DIRECTIVE_NODE = 7 + +type VueTemplateNode = { + type?: number + name?: string + arg?: { content?: string } + props?: VueTemplateNode[] + children?: VueTemplateNode[] + loc?: { start?: { line?: number; column?: number } } +} + +function walkVueTemplateAst(node: VueTemplateNode, visitor: (node: VueTemplateNode) => void) { + visitor(node) + for (const prop of node.props ?? []) walkVueTemplateAst(prop, visitor) + for (const child of node.children ?? []) walkVueTemplateAst(child, visitor) +} + +function vuePropName(prop: VueTemplateNode) { + if (prop.type === VUE_ATTRIBUTE_NODE) return prop.name ?? null + if (prop.type === VUE_DIRECTIVE_NODE && prop.name === 'bind') return prop.arg?.content ?? null + return null +} + +const noNativeTitleAttributesInVue = createTextRule( + 'open-pencil/no-native-title-attributes-in-vue', + (sourceRel, content) => { + if ( + !sourceRel.endsWith('.vue') || + (!sourceRel.startsWith('src/') && !sourceRel.startsWith('packages/vue/src/')) + ) { + return [] + } + + const template = parseVueSfc(content, { filename: sourceRel }).descriptor.template?.ast + if (!template) return [] + + const diagnostics: Array<{ message: string; line?: number; column?: number }> = [] + walkVueTemplateAst(template as VueTemplateNode, (node) => { + if (vuePropName(node) !== 'title') return + const loc = node.loc?.start + diagnostics.push({ + message: 'Use Tip/Reka tooltip patterns instead of native title attributes.', + line: loc?.line, + column: loc?.column + }) + }) + return diagnostics + } +) + const SHORTCUT_LABEL_PATTERN = /(?:Shift|Ctrl|Alt|Option|Cmd|Command|⌘|⇧|⌥|⌃)\s*[+)\w]/u const noShortcutTextInLabels = createTextRule( @@ -429,6 +482,7 @@ export const openPencilArchitecturePlugin = { noNonUiImportsInSharedUi, noAppImportsInSharedUi, noPropertyPanelInternalsOutsidePanel, + noNativeTitleAttributesInVue, noShortcutTextInLabels, noHardcodedMacOSShortcutGlyphs, noUiImportsInCore