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:
Danila Poyarkov 2026-07-18 00:23:38 +03:00
parent 69e39cc59e
commit 0abbb839cb
15 changed files with 175 additions and 59 deletions

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View 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
View 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

View 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

View file

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

View file

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

View file

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

View file

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