@@ -44,12 +66,8 @@ const emit = defineEmits<{
@toggle="actions.toggleExpand"
/>
-
-
{{ node.name }}
+
+
{{ node.name }}
-import { useTemplateRef, watch } from 'vue'
+import { computed, useTemplateRef, watch } from 'vue'
+import { tv } from 'tailwind-variants'
import { nodeIcon } from '@/app/editor/icons'
import LayerTreeDisclosure from './LayerTreeDisclosure.vue'
+import { useLayerTreeUI } from './ui'
+
+import layerTreeTheme from '@/theme/layer-tree'
import type { LayerNode } from '@open-pencil/vue'
import type { LayerRenameControls, LayerTreeItemActions } from './types'
-const { renameControls } = defineProps<{
+const { renameControls, expanded } = defineProps<{
node: LayerNode
hasChildren: boolean
padLeft: string
@@ -16,6 +20,9 @@ const { renameControls } = defineProps<{
}>()
const renameInput = useTemplateRef('renameInput')
+const ui = useLayerTreeUI()
+const layerTree = tv(layerTreeTheme)
+const styles = computed(() => layerTree({ expanded }))
watch(renameInput, (input) => {
if (input) void renameControls.focusInput(input)
@@ -23,18 +30,27 @@ watch(renameInput, (input) => {
-
+
-
+
+import LayerTreeNodeRow from '../LayerTreeNodeRow.vue'
+import LayerTreeRenameRow from '../LayerTreeRenameRow.vue'
+import { provideLayerTreeUI } from '../ui'
+
+import type { LayerNode } from '@open-pencil/vue'
+import type { LayerRenameControls, LayerTreeChrome, LayerTreeItemActions } from '../types'
+
+provideLayerTreeUI(() => undefined)
+
+function noop() {
+ return undefined
+}
+
+const actions: LayerTreeItemActions = {
+ select: noop,
+ toggleExpand: noop,
+ toggleVisibility: noop,
+ toggleLock: noop,
+ rename: noop
+}
+const renameControls: LayerRenameControls = {
+ commit: noop,
+ onKeydown: noop,
+ focusInput: async (input) => {
+ input.focus()
+ }
+}
+
+function node(id: string, overrides: Partial
= {}): LayerNode {
+ return {
+ id,
+ name: id,
+ type: 'RECTANGLE',
+ layoutMode: 'NONE',
+ visible: true,
+ locked: false,
+ ...overrides
+ }
+}
+
+function chrome(overrides: Partial = {}): LayerTreeChrome {
+ return {
+ draggingId: null,
+ instruction: null,
+ instructionTargetId: null,
+ focused: false,
+ indent: 16,
+ ...overrides
+ }
+}
+
+const states = [
+ { label: 'Normal', node: node('Normal'), selected: false, chrome: chrome() },
+ {
+ label: 'Selected focused',
+ node: node('Selected focused'),
+ selected: true,
+ chrome: chrome({ focused: true })
+ },
+ {
+ label: 'Selected unfocused',
+ node: node('Selected unfocused'),
+ selected: true,
+ chrome: chrome()
+ },
+ { label: 'Hidden', node: node('Hidden', { visible: false }), selected: false, chrome: chrome() },
+ { label: 'Locked', node: node('Locked', { locked: true }), selected: false, chrome: chrome() },
+ {
+ label: 'Component',
+ node: node('Component', { type: 'COMPONENT' }),
+ selected: false,
+ chrome: chrome()
+ },
+ {
+ label: 'Dragging',
+ node: node('Dragging'),
+ selected: false,
+ chrome: chrome({ draggingId: 'Dragging' })
+ },
+ {
+ label: 'Child drop',
+ node: node('Child drop'),
+ selected: false,
+ chrome: chrome({
+ instruction: { type: 'make-child' },
+ instructionTargetId: 'Child drop'
+ })
+ },
+ {
+ label: 'Drop above',
+ node: node('Drop above'),
+ selected: false,
+ chrome: chrome({
+ instruction: { type: 'reorder-above' },
+ instructionTargetId: 'Drop above'
+ })
+ },
+ {
+ label: 'Drop below',
+ node: node('Drop below'),
+ selected: false,
+ chrome: chrome({
+ instruction: { type: 'reorder-below' },
+ instructionTargetId: 'Drop below'
+ })
+ }
+]
+
+
+
+
+
+ Layer Tree states
+
+
+
+
diff --git a/src/components/LayerTree/types.ts b/src/components/LayerTree/types.ts
index c4634d7ad..4ae0fd875 100644
--- a/src/components/LayerTree/types.ts
+++ b/src/components/LayerTree/types.ts
@@ -12,6 +12,7 @@ export interface LayerTreeChrome {
draggingId: string | null
instruction: LayerDragInstruction | null
instructionTargetId: string | null
+ focused: boolean
indent: number
}
diff --git a/src/components/LayerTree/ui.ts b/src/components/LayerTree/ui.ts
new file mode 100644
index 000000000..a9888de8e
--- /dev/null
+++ b/src/components/LayerTree/ui.ts
@@ -0,0 +1,20 @@
+import { type ComputedRef, type InjectionKey, computed, inject, provide } from 'vue'
+
+import type { ComponentUI } from '@/components/ui/types'
+import type { LayerTreeTheme } from '@/theme/layer-tree'
+
+export type LayerTreeUI = ComponentUI
+
+const LAYER_TREE_UI_KEY: InjectionKey> =
+ Symbol('layer-tree-ui')
+
+export function provideLayerTreeUI(ui: () => LayerTreeUI | undefined) {
+ provide(LAYER_TREE_UI_KEY, computed(ui))
+}
+
+export function useLayerTreeUI(): ComputedRef {
+ return inject(
+ LAYER_TREE_UI_KEY,
+ computed(() => undefined)
+ )
+}
diff --git a/src/theme/layer-tree.ts b/src/theme/layer-tree.ts
new file mode 100644
index 000000000..63ec26ac0
--- /dev/null
+++ b/src/theme/layer-tree.ts
@@ -0,0 +1,92 @@
+const layerTreeTheme = {
+ slots: {
+ viewport: 'scrollbar-thin h-full overflow-y-auto px-1',
+ row: 'group/row relative flex w-full cursor-pointer items-center gap-1 rounded border-none bg-transparent py-1 pr-1 text-left text-xs text-surface hover:bg-hover',
+ disclosure:
+ 'flex w-4 shrink-0 cursor-pointer items-center justify-center text-muted transition-transform hover:text-surface',
+ disclosurePlaceholder: 'w-4 shrink-0',
+ icon: 'size-3 shrink-0',
+ label: 'min-w-0 flex-1 truncate',
+ actions: 'flex shrink-0 items-center gap-0.5',
+ action:
+ 'flex size-4 items-center justify-center rounded text-current outline-none hover:bg-white/15 focus-visible:ring-1 focus-visible:ring-panel-focus',
+ actionIcon: 'size-3',
+ dropIndicator: 'pointer-events-none absolute bg-accent',
+ renameRow: 'flex w-full items-center gap-1 py-1',
+ renameIcon: 'size-3 shrink-0 opacity-70',
+ renameInput:
+ 'min-w-0 flex-1 rounded border border-accent bg-input px-1 py-0 text-xs text-surface outline-none'
+ },
+ variants: {
+ selected: {
+ true: { row: 'bg-panel-selected text-surface hover:bg-panel-selected' },
+ false: { row: 'bg-transparent text-surface hover:bg-hover' }
+ },
+ focused: {
+ true: {},
+ false: {}
+ },
+ dragging: {
+ true: { row: 'opacity-30' },
+ false: {}
+ },
+ visible: {
+ true: {},
+ false: { row: 'opacity-50' }
+ },
+ component: {
+ true: { icon: 'text-component opacity-100' },
+ false: { icon: 'opacity-70' }
+ },
+ expanded: {
+ true: { disclosure: 'rotate-90' },
+ false: { disclosure: 'rotate-0' }
+ },
+ actionsVisible: {
+ true: {},
+ false: {
+ actions: 'opacity-0 group-hover/row:opacity-100 group-focus-within/row:opacity-100'
+ }
+ },
+ actionActive: {
+ true: { actionIcon: 'opacity-100' },
+ false: {
+ actionIcon: 'opacity-0 group-hover/row:opacity-70 group-focus-within/row:opacity-70'
+ }
+ },
+ childDropTarget: {
+ true: {
+ row: 'bg-accent/15 text-surface'
+ },
+ false: {}
+ },
+ dropPosition: {
+ child: {
+ dropIndicator: 'inset-y-1 rounded border border-accent bg-accent/10'
+ },
+ above: { dropIndicator: 'top-0 h-0.5' },
+ below: { dropIndicator: 'bottom-0 h-0.5' }
+ }
+ },
+ compoundVariants: [
+ {
+ selected: true,
+ focused: false,
+ class: { row: 'bg-panel-selected-muted text-surface hover:bg-panel-selected-muted' }
+ }
+ ],
+ defaultVariants: {
+ selected: false,
+ focused: false,
+ dragging: false,
+ visible: true,
+ component: false,
+ expanded: false,
+ actionsVisible: true,
+ actionActive: false,
+ childDropTarget: false
+ }
+}
+
+export type LayerTreeTheme = typeof layerTreeTheme
+export default layerTreeTheme
diff --git a/tests/e2e/layers/large-tree.spec.ts b/tests/e2e/layers/large-tree.spec.ts
index f472975c1..dc7a8e35b 100644
--- a/tests/e2e/layers/large-tree.spec.ts
+++ b/tests/e2e/layers/large-tree.spec.ts
@@ -2,19 +2,23 @@ import { test, expect } from '@playwright/test'
import { CanvasHelper } from '#tests/helpers/canvas'
-const NODE_COUNT = 1200
+const NODE_COUNT = 5000
test('large layer trees stay virtualized and scrollable', async ({ page }) => {
const canvas = new CanvasHelper(page)
await page.goto('/?test&no-rulers')
await canvas.waitForInit()
- await page.evaluate((count: number) => {
+ const replaceStartedAt = await page.evaluate((count: number) => {
const store = window.openPencil?.getStore?.()
if (!store) throw new Error('OpenPencil store not initialized')
+ const Graph = store.graph.constructor as new () => typeof store.graph
+ const graph = new Graph()
+ const pageId = graph.getPages()[0]?.id
+ if (!pageId) throw new Error('Page not initialized')
for (let i = 0; i < count; i++) {
- store.graph.createNode('RECTANGLE', store.state.currentPageId, {
+ graph.createNode('RECTANGLE', pageId, {
name: `Layer ${String(i + 1).padStart(4, '0')}`,
x: (i % 40) * 24,
y: Math.floor(i / 40) * 24,
@@ -30,7 +34,9 @@ test('large layer trees stay virtualized and scrollable', async ({ page }) => {
]
})
}
- store.requestRender()
+ const startedAt = performance.now()
+ store.replaceGraph(graph)
+ return startedAt
}, NODE_COUNT)
await canvas.waitForRender()
@@ -39,13 +45,21 @@ test('large layer trees stay virtualized and scrollable', async ({ page }) => {
const rows = page.getByTestId('layers-item')
await expect(rows.first()).toContainText('Layer 0001')
- await expect.poll(() => rows.count()).toBeLessThan(200)
+ await expect.poll(() => rows.count()).toBeLessThan(250)
+ await expect
+ .poll(() => page.evaluate((startedAt) => performance.now() - startedAt, replaceStartedAt))
+ .toBeLessThan(5000)
- await scroller.evaluate((el) => {
- el.scrollTop = el.scrollHeight
+ await page.evaluate(() => {
+ const store = window.openPencil?.getStore?.()
+ if (!store) throw new Error('OpenPencil store not initialized')
+ const pageNode = store.graph.getNode(store.state.currentPageId)
+ const lastId = pageNode?.childIds.at(-1)
+ if (!lastId) throw new Error('Last layer not found')
+ store.select([lastId])
})
- const lastRow = rows.filter({ hasText: 'Layer 1200' }).first()
+ const lastRow = rows.filter({ hasText: 'Layer 5000' }).first()
await expect(lastRow).toBeVisible()
const scrollBefore = await scroller.evaluate((el) => el.scrollTop)
@@ -78,5 +92,56 @@ test('large layer trees stay virtualized and scrollable', async ({ page }) => {
store.renameNode(lastId, 'Last layer renamed')
})
- await expect(rows.filter({ hasText: 'Last layer renamed' }).first()).toBeVisible()
+ const renamedRow = rows.filter({ hasText: 'Last layer renamed' }).first()
+ await expect(renamedRow).toBeVisible()
+ await renamedRow.dblclick()
+ const renameInput = page.getByTestId('layers-item-input')
+ await expect(renameInput).toBeFocused()
+
+ await page.evaluate(() => {
+ const store = window.openPencil?.getStore?.()
+ if (!store) throw new Error('OpenPencil store not initialized')
+ const firstId = store.graph.getNode(store.state.currentPageId)?.childIds[0]
+ if (!firstId) throw new Error('First layer not found')
+ store.updateNodeWithUndo(firstId, { y: 48 }, 'Move first layer again')
+ })
+
+ await expect(renameInput).toBeFocused()
+})
+
+test('layer tree supports range and additive selection', async ({ page }) => {
+ const canvas = new CanvasHelper(page)
+ await page.goto('/?test&no-rulers')
+ await canvas.waitForInit()
+
+ await page.evaluate(() => {
+ const store = window.openPencil?.getStore?.()
+ if (!store) throw new Error('OpenPencil store not initialized')
+ const Graph = store.graph.constructor as new () => typeof store.graph
+ const graph = new Graph()
+ const pageId = graph.getPages()[0]?.id
+ if (!pageId) throw new Error('Page not initialized')
+ for (let index = 1; index <= 8; index++) {
+ graph.createNode('RECTANGLE', pageId, { name: `Layer ${index}` })
+ }
+ store.replaceGraph(graph)
+ })
+
+ const rows = page.getByTestId('layers-item')
+ await rows.filter({ hasText: 'Layer 2' }).click()
+ await page.keyboard.down('Shift')
+ await rows.filter({ hasText: 'Layer 5' }).click()
+ await page.keyboard.up('Shift')
+
+ const selectedRows = page.getByTestId('layers-item').and(page.locator('[data-selected]'))
+ await expect(selectedRows).toHaveCount(4)
+ await expect(rows.filter({ hasText: 'Layer 2' })).toHaveAttribute('data-selected')
+ await expect(rows.filter({ hasText: 'Layer 5' })).toHaveAttribute('data-selected')
+
+ await page.keyboard.down('Meta')
+ await rows.filter({ hasText: 'Layer 7' }).click()
+ await page.keyboard.up('Meta')
+
+ await expect(selectedRows).toHaveCount(5)
+ await expect(rows.filter({ hasText: 'Layer 7' })).toHaveAttribute('data-selected')
})
diff --git a/tests/engine/vue/layer-tree/model.test.ts b/tests/engine/vue/layer-tree/model.test.ts
new file mode 100644
index 000000000..41fb0453e
--- /dev/null
+++ b/tests/engine/vue/layer-tree/model.test.ts
@@ -0,0 +1,101 @@
+import { describe, expect, test } from 'bun:test'
+
+import {
+ buildLayerTreeModel,
+ layerSelectionForTarget,
+ patchLayerNode,
+ visibleLayerRows
+} from '@open-pencil/vue'
+
+import { createRect, firstPageId, makeSceneGraph } from '#tests/helpers/scene'
+
+describe('layer tree model', () => {
+ test('builds indexed nested items in scene order', () => {
+ const graph = makeSceneGraph()
+ const pageId = firstPageId(graph)
+ const frame = graph.createNode('FRAME', pageId, { name: 'Frame' })
+ const child = createRect(graph, frame.id, { name: 'Child' })
+ const sibling = createRect(graph, pageId, { name: 'Sibling' })
+
+ const model = buildLayerTreeModel(graph, pageId)
+
+ expect(model.items.map((node) => node.id)).toEqual([frame.id, sibling.id])
+ expect(model.items[0]?.children?.map((node) => node.id)).toEqual([child.id])
+ expect(model.byId.get(child.id)?.name).toBe('Child')
+ })
+
+ test('derives only rows made visible by expansion', () => {
+ const graph = makeSceneGraph()
+ const pageId = firstPageId(graph)
+ const frame = graph.createNode('FRAME', pageId, { name: 'Frame' })
+ const nested = graph.createNode('FRAME', frame.id, { name: 'Nested' })
+ const child = createRect(graph, nested.id, { name: 'Child' })
+ const model = buildLayerTreeModel(graph, pageId)
+
+ expect(visibleLayerRows(model.items, new Set()).map((row) => row.node.id)).toEqual([frame.id])
+ expect(
+ visibleLayerRows(model.items, new Set([frame.id, nested.id])).map((row) => [
+ row.node.id,
+ row.level
+ ])
+ ).toEqual([
+ [frame.id, 1],
+ [nested.id, 2],
+ [child.id, 3]
+ ])
+ })
+
+ test('patches an indexed node without replacing its identity', () => {
+ const graph = makeSceneGraph()
+ const pageId = firstPageId(graph)
+ const sceneNode = createRect(graph, pageId, { name: 'Before' })
+ const model = buildLayerTreeModel(graph, pageId)
+ const layerNode = model.byId.get(sceneNode.id)
+ expect(layerNode).toBeDefined()
+ if (!layerNode) return
+
+ graph.updateNode(sceneNode.id, { name: 'After', visible: false })
+ const updated = graph.getNode(sceneNode.id)
+ expect(updated).toBeDefined()
+ if (!updated) return
+
+ expect(patchLayerNode(layerNode, updated)).toBe(true)
+ expect(model.items[0]).toBe(layerNode)
+ expect(layerNode).toMatchObject({ name: 'After', visible: false })
+ expect(patchLayerNode(layerNode, updated)).toBe(false)
+ })
+})
+
+describe('layer tree selection', () => {
+ const rows = ['a', 'b', 'c', 'd']
+
+ test('replaces, toggles, and ranges from the anchor', () => {
+ expect(
+ layerSelectionForTarget(rows, new Set(['a']), 'a', 'c', {
+ additive: false,
+ range: true
+ })
+ ).toEqual(new Set(['a', 'b', 'c']))
+ expect(
+ layerSelectionForTarget(rows, new Set(['a']), 'a', 'c', {
+ additive: true,
+ range: true
+ })
+ ).toEqual(new Set(['a', 'b', 'c']))
+ expect(
+ layerSelectionForTarget(rows, new Set(['a', 'b']), 'a', 'b', {
+ additive: true,
+ range: false
+ })
+ ).toEqual(new Set(['a']))
+ })
+
+ test('falls back to the target when the anchor is not visible', () => {
+ expect(
+ layerSelectionForTarget(rows, new Set(['a']), 'missing', 'd', {
+ additive: true,
+ range: true
+ })
+ ).toEqual(new Set(['d']))
+ })
+})
diff --git a/tools/architecture/package.json b/tools/architecture/package.json
index 0a09dc9c3..fd097e58f 100644
--- a/tools/architecture/package.json
+++ b/tools/architecture/package.json
@@ -1,5 +1,8 @@
{
"name": "@open-pencil/architecture-tools",
"private": true,
- "type": "module"
+ "type": "module",
+ "scripts": {
+ "test": "bun test"
+ }
}
diff --git a/tools/architecture/src/steiger-rules/dynamic-tailwind-classes.ts b/tools/architecture/src/steiger-rules/dynamic-tailwind-classes.ts
new file mode 100644
index 000000000..0e54dc921
--- /dev/null
+++ b/tools/architecture/src/steiger-rules/dynamic-tailwind-classes.ts
@@ -0,0 +1,140 @@
+import { parse as parseVueSfc } from 'vue/compiler-sfc'
+
+import { createTextRule } from './support.ts'
+
+const VUE_DIRECTIVE_NODE = 7
+
+const DYNAMIC_CLASS_ALLOWLIST = new Set([
+ 'src/components/CollabPanel/CollabAvatarStack.vue:37',
+ 'src/components/CollabPanel/CollabSharePopover.vue:20',
+ 'src/components/LayersPanel.vue:27',
+ 'src/components/LayersPanel.vue:35',
+ 'src/components/MobileHud/MobilePresencePopover.vue:52',
+ 'src/components/PagesPanel.vue:99',
+ 'src/components/PagesPanel.vue:133',
+ 'src/components/TabBar.vue:53',
+ 'src/components/Toolbar/MobileToolbar.vue:79',
+ 'src/components/Toolbar/MobileToolbar.vue:171',
+ 'src/components/Toolbar/ToolButton.vue:20',
+ 'src/components/Toolbar/ToolFlyout.vue:81',
+ 'src/components/Toolbar/ToolFlyout.vue:104',
+ 'src/components/chat/ProviderConnectionTestButton.vue:67',
+ 'src/components/fill-picker/GradientEditor.vue:52',
+ 'src/components/properties/LayoutSection/FlexControls.vue:225',
+ 'src/components/properties/binding/demo/BindingFieldDemoItem.vue:54',
+ 'src/components/variables/VariablesDialog.vue:269',
+ 'src/components/variables/VariablesDialog.vue:324'
+])
+
+type UnknownRecord = Record
+type ExpressionNode = UnknownRecord & { type: string }
+type VueTemplateNode = {
+ type?: number
+ name?: string
+ arg?: { content?: string }
+ exp?: { ast?: unknown }
+ props?: VueTemplateNode[]
+ children?: VueTemplateNode[]
+ loc?: { start?: { line?: number; column?: number } }
+}
+
+function isUnknownRecord(value: unknown): value is UnknownRecord {
+ return typeof value === 'object' && value !== null
+}
+
+function isExpressionNode(value: unknown): value is ExpressionNode {
+ return isUnknownRecord(value) && 'type' in value && typeof value.type === 'string'
+}
+
+function staticClassValues(node: unknown): string[] {
+ if (!isExpressionNode(node)) return []
+ if (node.type === 'StringLiteral' && typeof node.value === 'string') return [node.value]
+ if (
+ node.type === 'TemplateLiteral' &&
+ Array.isArray(node.expressions) &&
+ node.expressions.length === 0
+ ) {
+ const quasis = Array.isArray(node.quasis) ? node.quasis : []
+ return quasis.flatMap((quasi) => {
+ if (!isExpressionNode(quasi) || !isUnknownRecord(quasi.value)) return []
+ return typeof quasi.value.cooked === 'string' ? [quasi.value.cooked] : []
+ })
+ }
+ if (node.type === 'ArrayExpression' && Array.isArray(node.elements)) {
+ return node.elements.flatMap(staticClassValues)
+ }
+ return []
+}
+
+function propertyClassName(property: ExpressionNode): string | null {
+ if (property.type !== 'ObjectProperty' || !isExpressionNode(property.key)) return null
+ return property.key.type === 'StringLiteral' && typeof property.key.value === 'string'
+ ? property.key.value
+ : null
+}
+
+function hasDynamicClass(node: unknown, visited = new Set()): boolean {
+ if (!isExpressionNode(node) || visited.has(node)) return false
+ visited.add(node)
+
+ if (node.type === 'ConditionalExpression') {
+ const classes = [...staticClassValues(node.consequent), ...staticClassValues(node.alternate)]
+ if (classes.some((value) => value.trim().length > 0)) return true
+ }
+
+ if (node.type === 'ObjectExpression' && Array.isArray(node.properties)) {
+ for (const property of node.properties) {
+ if (!isExpressionNode(property)) continue
+ if (propertyClassName(property)?.trim()) return true
+ }
+ }
+
+ for (const [key, value] of Object.entries(node)) {
+ if (key === 'loc' || key === 'start' || key === 'end') continue
+ if (Array.isArray(value)) {
+ if (value.some((child) => hasDynamicClass(child, visited))) return true
+ } else if (hasDynamicClass(value, visited)) {
+ return true
+ }
+ }
+ return false
+}
+
+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)
+}
+
+export function dynamicClassDiagnostics(sourceRel: string, content: string) {
+ if (!sourceRel.startsWith('src/components/') || !sourceRel.endsWith('.vue')) 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 (
+ node.type !== VUE_DIRECTIVE_NODE ||
+ node.name !== 'bind' ||
+ node.arg?.content !== 'class' ||
+ !hasDynamicClass(node.exp?.ast)
+ ) {
+ return
+ }
+ const line = node.loc?.start?.line
+ if (line && DYNAMIC_CLASS_ALLOWLIST.has(`${sourceRel}:${line}`)) return
+ diagnostics.push({
+ message:
+ 'Move visual-state Tailwind classes into a typed src/theme/** Tailwind Variants theme and bind semantic data-* state.',
+ line,
+ column: node.loc?.start?.column
+ })
+ })
+ return diagnostics
+}
+
+export const noDynamicTailwindStateClasses = createTextRule(
+ 'open-pencil/no-dynamic-tailwind-state-classes',
+ dynamicClassDiagnostics
+)
diff --git a/tools/architecture/src/steiger-rules/index.ts b/tools/architecture/src/steiger-rules/index.ts
index 839352f94..fbc57102e 100644
--- a/tools/architecture/src/steiger-rules/index.ts
+++ b/tools/architecture/src/steiger-rules/index.ts
@@ -2,6 +2,7 @@ import path from 'node:path'
import { parse as parseVueSfc } from 'vue/compiler-sfc'
+import { noDynamicTailwindStateClasses } from './dynamic-tailwind-classes.ts'
import {
collectFolders,
createFileRule,
@@ -512,6 +513,7 @@ export const openPencilArchitecturePlugin = {
noAppImportsInSharedUi,
noPropertyPanelInternalsOutsidePanel,
noProductionTestIdsInSharedLayers,
+ noDynamicTailwindStateClasses,
noNativeTitleAttributesInVue,
noShortcutTextInLabels,
noHardcodedMacOSShortcutGlyphs,
diff --git a/tools/architecture/tests/dynamic-classes.test.ts b/tools/architecture/tests/dynamic-classes.test.ts
new file mode 100644
index 000000000..fd355360d
--- /dev/null
+++ b/tools/architecture/tests/dynamic-classes.test.ts
@@ -0,0 +1,50 @@
+import { describe, expect, test } from 'bun:test'
+
+import { dynamicClassDiagnostics } from '../src/steiger-rules/dynamic-tailwind-classes'
+
+function component(classBinding: string) {
+ return ``
+}
+
+describe('dynamic Tailwind state classes', () => {
+ test('reports conditional utility strings', () => {
+ const diagnostics = dynamicClassDiagnostics(
+ 'src/components/example/ExampleButton.vue',
+ component("active ? 'bg-accent text-white' : 'text-muted'")
+ )
+ expect(diagnostics).toHaveLength(1)
+ })
+
+ test('reports object-style utility maps', () => {
+ const diagnostics = dynamicClassDiagnostics(
+ 'src/components/example/ExampleButton.vue',
+ component("{ 'opacity-50': disabled }")
+ )
+ expect(diagnostics).toHaveLength(1)
+ })
+
+ test('allows only audited locations inside legacy files', () => {
+ const audited = `${'\n'.repeat(26)}`
+ expect(dynamicClassDiagnostics('src/components/LayersPanel.vue', audited)).toEqual([])
+ expect(
+ dynamicClassDiagnostics(
+ 'src/components/LayersPanel.vue',
+ component("active ? 'bg-hover' : 'text-muted'")
+ )
+ ).toHaveLength(1)
+ })
+
+ test('allows resolved theme slots and semantic state', () => {
+ const source = ``
+ expect(dynamicClassDiagnostics('src/components/example/ExampleButton.vue', source)).toEqual([])
+ })
+
+ test('ignores static classes', () => {
+ expect(
+ dynamicClassDiagnostics(
+ 'src/components/example/ExampleButton.vue',
+ ''
+ )
+ ).toEqual([])
+ })
+})