refactor(app): finish visual state theming
- Theme Variables, collaboration, presence, and provider status states\n- Expose semantic default, resizing, following, connection, and tone attributes\n- Remove the final dynamic-state allowlist and document enforced Vue template rules
This commit is contained in:
parent
69e39cc59e
commit
0abbb839cb
|
|
@ -273,6 +273,7 @@ Self-review checklist:
|
|||
|
||||
- Use reka-ui for UI components (Splitter, ContextMenu, DropdownMenu, etc.)
|
||||
- Vue UI styling APIs follow the Nuxt UI architecture: static Tailwind Variants themes live under `src/theme/**` with `slots`, `variants`, `compoundVariants`, and `defaultVariants`; components resolve the theme with `tv()` and merge per-instance `ui` overrides at each rendered slot. Single-root components expose `class` rather than a one-slot `ui` object. Do not add one-off `fooClass`, `barClass`, `emptyActionClass`, etc. props. Use `UI` casing in type names (`SelectUI`, not `SelectUi`).
|
||||
- Steiger parses Vue templates and rejects visual-state Tailwind utility branches, template-time `use*UI()` calls, and raw SVG app icons. Bind semantic state through `data-*` attributes and resolve typed theme variants in script instead of bypassing the rule.
|
||||
- Storybook is the internal component-state workshop (`bun run storybook`, `bun run build-storybook`), while VitePress is the canonical public SDK documentation. Colocate `*.stories.ts` with app UI components and use toolbar themes for light/dark states instead of adding test-only routes or showcase pages to the app.
|
||||
- Reuse colocated Vue demo components between Storybook and VitePress rather than maintaining separate examples. Style shared demos with Tailwind; the docs theme scans Vue SDK primitive demos through its dedicated Tailwind source.
|
||||
- Public component API tables are generated from Vue source and JSDoc with `vue-component-meta`; do not manually duplicate props, events, slots, or exposed APIs in Markdown. SDK examples are processed by VitePress Twoslash and must resolve against the public `@open-pencil/vue` API.
|
||||
|
|
|
|||
|
|
@ -57,6 +57,7 @@
|
|||
--color-warning-text: #fde68a;
|
||||
--color-warning-action: #fcd34d;
|
||||
--color-success: #4ade80;
|
||||
--color-error: #f87171;
|
||||
--color-success-bg: #16a34a;
|
||||
--color-success-bg-hover: #15803d;
|
||||
--color-code-tag: #7dd3fc;
|
||||
|
|
@ -93,6 +94,7 @@ html[data-theme='light'] {
|
|||
--color-warning-text: #92400e;
|
||||
--color-warning-action: #78350f;
|
||||
--color-success: #15803d;
|
||||
--color-error: #b91c1c;
|
||||
--color-success-bg: #16a34a;
|
||||
--color-success-bg-hover: #15803d;
|
||||
--color-code-tag: #0369a1;
|
||||
|
|
|
|||
|
|
@ -1,13 +1,22 @@
|
|||
<script setup lang="ts">
|
||||
import { tv } from 'tailwind-variants'
|
||||
|
||||
import { colorToCSS } from '@open-pencil/core/color'
|
||||
|
||||
import Tip from '@/components/ui/Tip.vue'
|
||||
import { initials } from '@/app/shell/ui'
|
||||
import { useCollabPanelContext } from '@/components/CollabPanel/context'
|
||||
import collaborationTheme from '@/theme/collaboration'
|
||||
import { useI18n } from '@open-pencil/vue'
|
||||
|
||||
const collab = useCollabPanelContext()
|
||||
const { dialogs } = useI18n()
|
||||
const collaboration = tv(collaborationTheme)
|
||||
const avatar = collaboration({ size: 'sm', bordered: true })
|
||||
|
||||
function peerAvatarClass(following: boolean) {
|
||||
return collaboration({ size: 'sm', bordered: true, following }).avatar()
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
|
|
@ -15,7 +24,7 @@ const { dialogs } = useI18n()
|
|||
<Tip :label="`${collab.state.localName || dialogs.you} (${dialogs.youSuffix})`">
|
||||
<div
|
||||
data-test-id="collab-local-avatar"
|
||||
class="flex size-6 items-center justify-center rounded-full border-2 border-panel text-[10px] font-semibold text-white"
|
||||
:class="avatar.avatar()"
|
||||
:style="{ background: colorToCSS(collab.state.localColor) }"
|
||||
>
|
||||
{{ initials(collab.state.localName || dialogs.you) }}
|
||||
|
|
@ -33,12 +42,8 @@ const { dialogs } = useI18n()
|
|||
>
|
||||
<div
|
||||
data-test-id="collab-peer-avatar"
|
||||
class="flex size-6 cursor-pointer items-center justify-center rounded-full border-2 text-[10px] font-semibold text-white transition-all"
|
||||
:class="
|
||||
collab.followingPeer === peer.clientId
|
||||
? 'border-white ring-2 ring-white/40'
|
||||
: 'border-panel'
|
||||
"
|
||||
:data-following="collab.followingPeer === peer.clientId || undefined"
|
||||
:class="[peerAvatarClass(collab.followingPeer === peer.clientId), avatar.peerAvatar()]"
|
||||
:style="{ background: colorToCSS(peer.color) }"
|
||||
@click="collab.toggleFollowPeer(peer.clientId)"
|
||||
>
|
||||
|
|
|
|||
|
|
@ -1,4 +1,6 @@
|
|||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { tv } from 'tailwind-variants'
|
||||
import { PopoverContent, PopoverPortal, PopoverRoot, PopoverTrigger } from 'reka-ui'
|
||||
|
||||
import ConnectedRoom from '@/components/CollabPanel/ConnectedRoom.vue'
|
||||
|
|
@ -6,9 +8,17 @@ import JoinRoomPrompt from '@/components/CollabPanel/JoinRoomPrompt.vue'
|
|||
import ShareOrJoinRoom from '@/components/CollabPanel/ShareOrJoinRoom.vue'
|
||||
import { useCollabPanelContext } from '@/components/CollabPanel/context'
|
||||
import { usePopoverUI } from '@/components/ui/popover'
|
||||
import collaborationTheme from '@/theme/collaboration'
|
||||
|
||||
const collab = useCollabPanelContext()
|
||||
const cls = usePopoverUI({ content: 'z-50 w-72 p-3' })
|
||||
const connection = computed(() => {
|
||||
if (collab.state.connected) return 'connected'
|
||||
if (collab.isJoining) return 'joining'
|
||||
return 'idle'
|
||||
})
|
||||
const collaboration = tv(collaborationTheme)
|
||||
const styles = computed(() => collaboration({ connection: connection.value }))
|
||||
</script>
|
||||
|
||||
<template>
|
||||
|
|
@ -16,14 +26,8 @@ const cls = usePopoverUI({ content: 'z-50 w-72 p-3' })
|
|||
<PopoverTrigger as-child>
|
||||
<button
|
||||
data-test-id="collab-share-button"
|
||||
class="flex h-7 cursor-pointer items-center gap-1.5 rounded-md border-none px-3 text-xs font-medium transition-colors"
|
||||
:class="
|
||||
collab.state.connected
|
||||
? 'bg-[var(--color-success-bg)] text-white hover:bg-[var(--color-success-bg-hover)]'
|
||||
: collab.isJoining
|
||||
? 'animate-pulse border border-[var(--color-warning-border)] bg-[var(--color-warning-bg)] text-[var(--color-warning-text)]'
|
||||
: 'bg-accent text-white hover:bg-accent/90'
|
||||
"
|
||||
:data-connection="connection"
|
||||
:class="styles.shareButton()"
|
||||
>
|
||||
<icon-lucide-share-2 class="size-3.5" />
|
||||
{{
|
||||
|
|
|
|||
|
|
@ -1,20 +1,26 @@
|
|||
<script setup lang="ts">
|
||||
import { tv } from 'tailwind-variants'
|
||||
import { PopoverContent, PopoverPortal, PopoverRoot, PopoverTrigger } from 'reka-ui'
|
||||
|
||||
import { initials } from '@/app/shell/ui'
|
||||
import { colorToCSS } from '@open-pencil/core/color'
|
||||
import { useMobileHudContext } from '@/components/MobileHud/context'
|
||||
import collaborationTheme from '@/theme/collaboration'
|
||||
|
||||
const hud = useMobileHudContext()
|
||||
const collaboration = tv(collaborationTheme)
|
||||
const styles = collaboration({ size: 'md' })
|
||||
|
||||
function peerAvatarClass(following: boolean) {
|
||||
return collaboration({ size: 'md', following }).avatar()
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<PopoverRoot v-if="hud.collabState.connected">
|
||||
<PopoverTrigger as-child>
|
||||
<button
|
||||
class="flex h-8 cursor-pointer items-center gap-1.5 rounded-full border border-white/10 bg-panel/70 px-3 shadow-md backdrop-blur-xl select-none active:bg-hover"
|
||||
>
|
||||
<span class="size-2 rounded-full bg-green-500" />
|
||||
<button :class="styles.presenceTrigger()">
|
||||
<span :class="styles.presenceDot()" />
|
||||
<span class="text-xs text-surface">Online: {{ hud.onlineCount }}</span>
|
||||
</button>
|
||||
</PopoverTrigger>
|
||||
|
|
@ -24,13 +30,13 @@ const hud = useMobileHudContext()
|
|||
:side-offset="8"
|
||||
side="bottom"
|
||||
align="center"
|
||||
class="z-50 w-56 rounded-xl border border-border bg-panel p-3 shadow-xl"
|
||||
:class="styles.presenceContent()"
|
||||
>
|
||||
<div class="mb-2 text-[11px] tracking-wider text-muted uppercase">In this room</div>
|
||||
<div class="flex flex-col gap-2">
|
||||
<div class="flex items-center gap-2">
|
||||
<div
|
||||
class="flex size-7 shrink-0 items-center justify-center rounded-full text-[10px] font-semibold text-white"
|
||||
:class="styles.avatar()"
|
||||
:style="{ background: colorToCSS(hud.collabState.localColor) }"
|
||||
>
|
||||
{{ initials(hud.collabState.localName || 'You') }}
|
||||
|
|
@ -44,12 +50,12 @@ const hud = useMobileHudContext()
|
|||
<div
|
||||
v-for="peer in hud.collabPeers"
|
||||
:key="peer.clientId"
|
||||
class="flex cursor-pointer items-center gap-2 rounded-md px-0.5 py-0.5 select-none active:bg-hover"
|
||||
:data-following="hud.followingPeer === peer.clientId || undefined"
|
||||
:class="styles.peerRow()"
|
||||
@click="hud.toggleFollowPeer(peer.clientId)"
|
||||
>
|
||||
<div
|
||||
class="flex size-7 shrink-0 items-center justify-center rounded-full text-[10px] font-semibold text-white"
|
||||
:class="hud.followingPeer === peer.clientId ? 'ring-2 ring-white/40' : ''"
|
||||
:class="[peerAvatarClass(hud.followingPeer === peer.clientId), styles.peerAvatar()]"
|
||||
:style="{ background: colorToCSS(peer.color) }"
|
||||
>
|
||||
{{ initials(peer.name) }}
|
||||
|
|
@ -61,12 +67,7 @@ const hud = useMobileHudContext()
|
|||
</div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
class="mt-3 flex h-7 w-full cursor-pointer items-center justify-center rounded border border-border bg-transparent text-xs text-muted select-none active:bg-hover"
|
||||
@click="hud.disconnect"
|
||||
>
|
||||
Disconnect
|
||||
</button>
|
||||
<button :class="styles.disconnect()" @click="hud.disconnect">Disconnect</button>
|
||||
</PopoverContent>
|
||||
</PopoverPortal>
|
||||
</PopoverRoot>
|
||||
|
|
|
|||
|
|
@ -1,7 +1,10 @@
|
|||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { tv } from 'tailwind-variants'
|
||||
import { useI18n } from '@open-pencil/vue'
|
||||
|
||||
import statusTheme from '@/theme/status'
|
||||
|
||||
import type { ProviderConnectionTestFailureReason } from '@/app/ai/chat/connection-test'
|
||||
|
||||
interface ProviderConnectionTestButtonProps {
|
||||
|
|
@ -43,6 +46,8 @@ const resultMessage = computed(() => {
|
|||
})
|
||||
|
||||
const isTesting = computed(() => status === 'testing')
|
||||
const resultTone = computed(() => (status === 'success' ? 'success' : 'error'))
|
||||
const statusStyles = computed(() => tv(statusTheme)({ tone: resultTone.value }))
|
||||
</script>
|
||||
|
||||
<template>
|
||||
|
|
@ -63,8 +68,8 @@ const isTesting = computed(() => status === 'testing')
|
|||
|
||||
<p
|
||||
v-if="resultMessage"
|
||||
class="text-[10px] leading-snug"
|
||||
:class="status === 'success' ? 'text-green-400' : 'text-red-400'"
|
||||
:data-tone="resultTone"
|
||||
:class="statusStyles.text()"
|
||||
data-test-id="provider-test-connection-result"
|
||||
>
|
||||
{{ resultMessage }}
|
||||
|
|
|
|||
|
|
@ -50,8 +50,7 @@ function tooltip(variableName: string, resolvedValue: unknown) {
|
|||
<div
|
||||
v-bind="{ ...attrs, ...binding.stateAttrs }"
|
||||
data-story-control
|
||||
class="group/binding flex h-control min-w-0 items-center rounded-panel border border-transparent bg-panel-field text-xs text-surface outline-none hover:bg-panel-field-hover focus-within:border-panel-focus"
|
||||
:class="derived ? 'text-muted' : ''"
|
||||
class="group/binding flex h-control min-w-0 items-center rounded-panel border border-transparent bg-panel-field text-xs text-surface outline-none hover:bg-panel-field-hover focus-within:border-panel-focus data-[derived]:text-muted"
|
||||
:data-derived="derived ? '' : undefined"
|
||||
@pointerdown="
|
||||
!editing &&
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
<script setup lang="ts">
|
||||
import { watch, type Component } from 'vue'
|
||||
import { tv } from 'tailwind-variants'
|
||||
import { templateRef } from '@vueuse/core'
|
||||
import {
|
||||
ContextMenuContent,
|
||||
|
|
@ -38,12 +39,15 @@ import ColorInput from '@/components/ColorPicker/ColorInput.vue'
|
|||
import Tip from '@/components/ui/Tip.vue'
|
||||
import { useDialogUI } from '@/components/ui/dialog'
|
||||
import { useMenuUI } from '@/components/ui/menu'
|
||||
import variableTableTheme from '@/theme/variable-table'
|
||||
|
||||
import type { VariableType } from '@open-pencil/scene-graph'
|
||||
|
||||
const open = defineModel<boolean>('open', { default: false })
|
||||
const cls = useDialogUI({ content: 'flex h-[75vh] w-[800px] max-w-[90vw] flex-col' })
|
||||
const menuCls = useMenuUI({ content: 'w-40' })
|
||||
const variableTable = tv(variableTableTheme)
|
||||
const tableStyles = variableTable()
|
||||
|
||||
const variableTypeIcons: Record<VariableType, Component> = {
|
||||
COLOR: IconPalette,
|
||||
|
|
@ -104,6 +108,14 @@ function getModeId(columnId: string): string | undefined {
|
|||
function modeId(columnId: string): string {
|
||||
return columnId.slice(5)
|
||||
}
|
||||
|
||||
function modeLabelClass(defaultMode: boolean) {
|
||||
return variableTable({ defaultMode }).modeLabel()
|
||||
}
|
||||
|
||||
function resizeHandleClass(resizing: boolean) {
|
||||
return variableTable({ resizing }).resizeHandle()
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
|
|
@ -265,11 +277,11 @@ function modeId(columnId: string): string {
|
|||
<ContextMenuRoot v-else>
|
||||
<ContextMenuTrigger as-child>
|
||||
<span
|
||||
class="cursor-default"
|
||||
:data-default="
|
||||
getModeId(header.column.id) === col.defaultModeId || undefined
|
||||
"
|
||||
:class="
|
||||
getModeId(header.column.id) === col.defaultModeId
|
||||
? 'text-surface'
|
||||
: ''
|
||||
modeLabelClass(getModeId(header.column.id) === col.defaultModeId)
|
||||
"
|
||||
@dblclick="ctx.startRenameMode(modeId(header.column.id))"
|
||||
>
|
||||
|
|
@ -320,12 +332,8 @@ function modeId(columnId: string): string {
|
|||
/>
|
||||
<div
|
||||
v-if="header.column.getCanResize()"
|
||||
class="absolute top-0 right-0 h-full w-1 cursor-col-resize touch-none select-none"
|
||||
:class="
|
||||
header.column.getIsResizing()
|
||||
? 'bg-accent'
|
||||
: 'bg-transparent hover:bg-border'
|
||||
"
|
||||
:data-resizing="header.column.getIsResizing() || undefined"
|
||||
:class="resizeHandleClass(header.column.getIsResizing())"
|
||||
@mousedown="header.getResizeHandler()?.($event)"
|
||||
@touchstart="header.getResizeHandler()?.($event)"
|
||||
@dblclick="header.column.resetSize()"
|
||||
|
|
@ -349,7 +357,7 @@ function modeId(columnId: string): string {
|
|||
v-for="row in ctx.table.getRowModel().rows"
|
||||
:key="row.id"
|
||||
data-test-id="variable-row"
|
||||
class="group border-b border-border/30 hover:bg-hover/50"
|
||||
:class="tableStyles.row()"
|
||||
>
|
||||
<td
|
||||
v-for="cell in row.getVisibleCells()"
|
||||
|
|
|
|||
58
src/theme/collaboration.ts
Normal file
58
src/theme/collaboration.ts
Normal file
|
|
@ -0,0 +1,58 @@
|
|||
const collaborationTheme = {
|
||||
slots: {
|
||||
avatar:
|
||||
'flex shrink-0 items-center justify-center rounded-full text-[10px] font-semibold text-white',
|
||||
peerAvatar: 'cursor-pointer transition-all',
|
||||
shareButton:
|
||||
'flex h-7 cursor-pointer items-center gap-1.5 rounded-md border-none px-3 text-xs font-medium transition-colors outline-none focus-visible:ring-1 focus-visible:ring-accent',
|
||||
presenceTrigger:
|
||||
'flex h-8 cursor-pointer items-center gap-1.5 rounded-full border border-white/10 bg-panel/70 px-3 shadow-md backdrop-blur-xl outline-none select-none active:bg-hover focus-visible:ring-1 focus-visible:ring-accent',
|
||||
presenceDot: 'size-2 rounded-full bg-green-500',
|
||||
presenceContent: 'z-50 w-56 rounded-xl border border-border bg-panel p-3 shadow-xl',
|
||||
peerRow:
|
||||
'flex cursor-pointer items-center gap-2 rounded-md px-0.5 py-0.5 outline-none select-none active:bg-hover focus-visible:ring-1 focus-visible:ring-accent',
|
||||
disconnect:
|
||||
'mt-3 flex h-7 w-full cursor-pointer items-center justify-center rounded border border-border bg-transparent text-xs text-muted outline-none select-none active:bg-hover focus-visible:ring-1 focus-visible:ring-accent'
|
||||
},
|
||||
variants: {
|
||||
following: {
|
||||
true: { avatar: 'ring-2 ring-white/40' },
|
||||
false: {}
|
||||
},
|
||||
bordered: {
|
||||
true: { avatar: 'border-2 border-panel' },
|
||||
false: {}
|
||||
},
|
||||
connection: {
|
||||
idle: { shareButton: 'bg-accent text-white hover:bg-accent/90' },
|
||||
joining: {
|
||||
shareButton:
|
||||
'animate-pulse border border-[var(--color-warning-border)] bg-[var(--color-warning-bg)] text-[var(--color-warning-text)]'
|
||||
},
|
||||
connected: {
|
||||
shareButton:
|
||||
'bg-[var(--color-success-bg)] text-white hover:bg-[var(--color-success-bg-hover)]'
|
||||
}
|
||||
},
|
||||
size: {
|
||||
sm: { avatar: 'size-6' },
|
||||
md: { avatar: 'size-7' }
|
||||
}
|
||||
},
|
||||
compoundVariants: [
|
||||
{
|
||||
following: true,
|
||||
bordered: true,
|
||||
class: { avatar: 'border-white' }
|
||||
}
|
||||
],
|
||||
defaultVariants: {
|
||||
following: false,
|
||||
bordered: false,
|
||||
connection: 'idle' as const,
|
||||
size: 'sm' as const
|
||||
}
|
||||
}
|
||||
|
||||
export type CollaborationTheme = typeof collaborationTheme
|
||||
export default collaborationTheme
|
||||
19
src/theme/status.ts
Normal file
19
src/theme/status.ts
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
const statusTheme = {
|
||||
slots: {
|
||||
text: 'text-[10px] leading-snug'
|
||||
},
|
||||
variants: {
|
||||
tone: {
|
||||
neutral: { text: 'text-muted' },
|
||||
success: { text: 'text-[var(--color-success)]' },
|
||||
warning: { text: 'text-[var(--color-warning-text)]' },
|
||||
error: { text: 'text-[var(--color-error)]' }
|
||||
}
|
||||
},
|
||||
defaultVariants: {
|
||||
tone: 'neutral' as const
|
||||
}
|
||||
}
|
||||
|
||||
export type StatusTheme = typeof statusTheme
|
||||
export default statusTheme
|
||||
24
src/theme/variable-table.ts
Normal file
24
src/theme/variable-table.ts
Normal file
|
|
@ -0,0 +1,24 @@
|
|||
const variableTableTheme = {
|
||||
slots: {
|
||||
modeLabel: 'cursor-default',
|
||||
resizeHandle: 'absolute top-0 right-0 h-full w-1 cursor-col-resize touch-none select-none',
|
||||
row: 'group border-b border-border/30 hover:bg-hover/50'
|
||||
},
|
||||
variants: {
|
||||
defaultMode: {
|
||||
true: { modeLabel: 'text-surface' },
|
||||
false: {}
|
||||
},
|
||||
resizing: {
|
||||
true: { resizeHandle: 'bg-accent' },
|
||||
false: { resizeHandle: 'bg-transparent hover:bg-border' }
|
||||
}
|
||||
},
|
||||
defaultVariants: {
|
||||
defaultMode: false,
|
||||
resizing: false
|
||||
}
|
||||
}
|
||||
|
||||
export type VariableTableTheme = typeof variableTableTheme
|
||||
export default variableTableTheme
|
||||
|
|
@ -147,7 +147,7 @@ test('clicking AI tab shows provider setup when no key set', async () => {
|
|||
test('saving API key shows chat interface', async () => {
|
||||
const key = USE_REAL_LLM ? OPENROUTER_KEY : 'sk-or-test-key-12345'
|
||||
await apiKeyInput().fill(key)
|
||||
await page.locator('button:has-text("Connect")').click()
|
||||
await page.getByTestId('api-key-save').click()
|
||||
|
||||
await expect(chatInput()).toBeVisible()
|
||||
await expect(page.getByText('Describe what you want to create or change.')).toBeVisible()
|
||||
|
|
|
|||
|
|
@ -30,6 +30,7 @@ test('variables dialog opens', async () => {
|
|||
|
||||
await openVariables().click()
|
||||
await expect(editor.page.getByTestId('variables-dialog')).toBeVisible()
|
||||
await expect(editor.page.locator('[data-default="true"]')).toHaveCount(1)
|
||||
editor.canvas.assertNoErrors()
|
||||
})
|
||||
|
||||
|
|
|
|||
|
|
@ -4,16 +4,6 @@ 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/MobileHud/MobilePresencePopover.vue:52',
|
||||
'src/components/chat/ProviderConnectionTestButton.vue:67',
|
||||
'src/components/properties/binding/demo/BindingFieldDemoItem.vue:54',
|
||||
'src/components/variables/VariablesDialog.vue:269',
|
||||
'src/components/variables/VariablesDialog.vue:324'
|
||||
])
|
||||
|
||||
type UnknownRecord = Record<string, unknown>
|
||||
type ExpressionNode = UnknownRecord & { type: string }
|
||||
type VueTemplateNode = {
|
||||
|
|
@ -112,7 +102,6 @@ export function dynamicClassDiagnostics(sourceRel: string, content: string) {
|
|||
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.',
|
||||
|
|
|
|||
|
|
@ -42,11 +42,11 @@ describe('dynamic Tailwind state classes', () => {
|
|||
expect(diagnostics).toHaveLength(1)
|
||||
})
|
||||
|
||||
test('allows only audited locations inside legacy files', () => {
|
||||
test('rejects dynamic utility state in previously audited files', () => {
|
||||
const audited = `<template>${'\n'.repeat(36)}<button :class="active ? 'bg-hover' : 'text-muted'" /></template>`
|
||||
expect(
|
||||
dynamicClassDiagnostics('src/components/CollabPanel/CollabAvatarStack.vue', audited)
|
||||
).toEqual([])
|
||||
).toHaveLength(1)
|
||||
expect(
|
||||
dynamicClassDiagnostics(
|
||||
'src/components/LayersPanel.vue',
|
||||
|
|
|
|||
Loading…
Reference in a new issue