Merge branch 'open-pencil:master' into master

This commit is contained in:
Sadko 2026-07-17 17:28:32 -04:00 committed by GitHub
commit d78f4bb64e
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
30 changed files with 440 additions and 127 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

@ -58,6 +58,7 @@ function activeKeyForTool(tool: EditorToolDef) {
:data-test-id="toolbarToolTestId(tool.key)"
:icon="toolIcons[tool.key]"
:active="active || isActive(tool)"
:ui="ui"
@click="actions.select"
/>
</Tip>

View file

@ -1,4 +1,5 @@
<script setup lang="ts">
import { tv } from 'tailwind-variants'
import { AnimatePresence, motion } from 'motion-v'
import IconChevronLeft from '~icons/lucide/chevron-left'
@ -7,6 +8,7 @@ import IconChevronRight from '~icons/lucide/chevron-right'
import ToolButton from '@/components/Toolbar/ToolButton.vue'
import ToolFlyout from '@/components/Toolbar/ToolFlyout.vue'
import ToolbarActionGroup from '@/components/Toolbar/ToolbarActionGroup.vue'
import toolbarTheme from '@/theme/toolbar'
import { toolbarToolTestId, ToolbarItem } from '@open-pencil/vue'
import type { Tool } from '@open-pencil/vue'
@ -46,6 +48,9 @@ const {
arrangeActions: ToolbarActionItem[]
}>()
const toolbar = tv(toolbarTheme)
const styles = toolbar()
const emit = defineEmits<{
setTool: [tool: Tool]
prev: []
@ -62,6 +67,10 @@ const slideVariants = {
function activeKeyForTool(tool: EditorToolDef) {
return tool.flyout?.includes(activeTool) ? activeTool : tool.key
}
function navigationClass(disabled: boolean) {
return toolbar({ disabled }).navigationAction({ class: ui?.navigationAction })
}
</script>
<template>
@ -75,13 +84,14 @@ function activeKeyForTool(tool: EditorToolDef) {
>
<motion.button
data-test-id="mobile-toolbar-prev"
class="flex size-7 shrink-0 cursor-pointer items-center justify-center rounded-full border border-border bg-panel shadow-sm select-none"
:class="hasPrev ? 'text-muted' : 'pointer-events-none'"
:disabled="!hasPrev"
:data-disabled="!hasPrev || undefined"
:class="navigationClass(!hasPrev)"
:animate="{ opacity: hasPrev ? 1 : 0 }"
:transition="{ duration: 0.15 }"
@click="emit('prev')"
>
<IconChevronLeft class="size-3.5" />
<IconChevronLeft :class="styles.navigationIcon({ class: ui?.navigationIcon })" />
</motion.button>
<motion.div
@ -121,6 +131,7 @@ function activeKeyForTool(tool: EditorToolDef) {
:data-test-id="toolbarToolTestId(tool.key, true)"
:icon="toolIcons[tool.key]"
:active="active || activeKeyForTool(tool) === activeTool"
:ui="ui"
@click="actions.select"
/>
</ToolbarItem>
@ -140,6 +151,7 @@ function activeKeyForTool(tool: EditorToolDef) {
>
<ToolbarActionGroup
:actions="editActions"
:ui="ui"
test-prefix="mobile-toolbar"
@action="emit('action', $event)"
/>
@ -158,6 +170,7 @@ function activeKeyForTool(tool: EditorToolDef) {
>
<ToolbarActionGroup
:actions="arrangeActions"
:ui="ui"
test-prefix="mobile-toolbar"
@action="emit('action', $event)"
/>
@ -167,13 +180,14 @@ function activeKeyForTool(tool: EditorToolDef) {
<motion.button
data-test-id="mobile-toolbar-next"
class="flex size-7 shrink-0 cursor-pointer items-center justify-center rounded-full border border-border bg-panel shadow-sm select-none"
:class="hasNext ? 'text-muted' : 'pointer-events-none'"
:disabled="!hasNext"
:data-disabled="!hasNext || undefined"
:class="navigationClass(!hasNext)"
:animate="{ opacity: hasNext ? 1 : 0 }"
:transition="{ duration: 0.15 }"
@click="emit('next')"
>
<IconChevronRight class="size-3.5" />
<IconChevronRight :class="styles.navigationIcon({ class: ui?.navigationIcon })" />
</motion.button>
</div>
</template>

View file

@ -1,13 +1,22 @@
<script setup lang="ts">
import { computed } from 'vue'
import { tv } from 'tailwind-variants'
import toolbarTheme from '@/theme/toolbar'
import type { Component } from 'vue'
import type { ToolbarUI } from '@/components/Toolbar/types'
interface ToolButtonProps {
icon: Component
active?: boolean
mobile?: boolean
ui?: ToolbarUI
}
const { icon, active = false, mobile = false } = defineProps<ToolButtonProps>()
const { icon, active = false, mobile = false, ui } = defineProps<ToolButtonProps>()
const toolbar = tv(toolbarTheme)
const styles = computed(() => toolbar({ active, mobile }))
const emit = defineEmits<{
click: []
@ -16,17 +25,11 @@ const emit = defineEmits<{
<template>
<button
class="flex size-8 cursor-pointer items-center justify-center border-none transition-colors"
:class="[
mobile ? 'rounded-[6px] select-none' : 'rounded-lg',
active
? 'bg-accent text-white'
: mobile
? 'bg-transparent text-muted active:bg-hover'
: 'bg-transparent text-muted hover:bg-hover hover:text-surface'
]"
:data-active="active || undefined"
:data-mobile="mobile || undefined"
:class="styles.button({ class: ui?.button })"
@click="emit('click')"
>
<component :is="icon" class="size-4" />
<component :is="icon" :class="styles.icon({ class: ui?.icon })" />
</button>
</template>

View file

@ -1,4 +1,6 @@
<script setup lang="ts">
import { computed } from 'vue'
import { tv } from 'tailwind-variants'
import {
DropdownMenuContent,
DropdownMenuItem,
@ -11,6 +13,7 @@ import IconChevronDown from '~icons/lucide/chevron-down'
import AppShortcutText from '@/components/ui/AppShortcutText.vue'
import { menu } from '@/components/ui/menu'
import toolbarTheme from '@/theme/toolbar'
import ToolButton from '@/components/Toolbar/ToolButton.vue'
import {
toolbarFlyoutItemTestId,
@ -42,6 +45,10 @@ const {
mobile?: boolean
}>()
const toolbar = tv(toolbarTheme)
const triggerActive = computed(() => isActiveTool(activeKeyForTool()))
const styles = computed(() => toolbar({ active: triggerActive.value, mobile }))
const emit = defineEmits<{
select: [tool: Tool]
}>()
@ -59,16 +66,21 @@ function isActiveTool(key: Tool) {
function activeKeyForTool() {
return tool.flyout?.includes(activeTool) ? activeTool : tool.key
}
function flyoutItemClass(subActive: boolean) {
return menu().item({ class: toolbar({ subActive }).flyoutItem({ class: ui?.flyoutItem }) })
}
</script>
<template>
<div class="flex items-center">
<div :class="styles.flyoutGroup({ class: ui?.flyoutGroup })">
<slot :label="`${toolLabels[activeKeyForTool()]} (${tool.shortcut})`">
<ToolButton
:data-test-id="toolbarToolTestId(activeKeyForTool(), mobile)"
:icon="toolIcons[activeKeyForTool()]"
:active="isActiveTool(activeKeyForTool())"
:active="triggerActive"
:mobile="mobile"
:ui="ui"
@click="emit('select', activeKeyForTool())"
/>
</slot>
@ -77,22 +89,21 @@ function activeKeyForTool() {
<DropdownMenuTrigger as-child>
<button
v-test-id="toolbarFlyoutTestId(tool.key, mobile)"
class="flex h-8 w-3 cursor-pointer items-center justify-center border-none transition-colors"
:class="[
mobile ? 'rounded-[6px] select-none' : 'rounded-lg',
isActiveTool(activeKeyForTool())
? 'bg-accent text-white'
: mobile
? 'bg-transparent text-muted active:bg-hover'
: 'bg-transparent text-muted hover:bg-hover hover:text-surface'
]"
:data-active="triggerActive || undefined"
:data-mobile="mobile || undefined"
:class="styles.flyoutTrigger({ class: ui?.flyoutTrigger })"
>
<IconChevronDown class="size-2.5" />
<IconChevronDown :class="styles.flyoutTriggerIcon({ class: ui?.flyoutTriggerIcon })" />
</button>
</DropdownMenuTrigger>
<DropdownMenuPortal>
<DropdownMenuContent side="top" :side-offset="8" align="start" :class="ui?.flyoutContent">
<DropdownMenuContent
side="top"
:side-offset="8"
align="start"
:class="styles.flyoutContent({ class: ui?.flyoutContent })"
>
<ToolbarItem
v-for="sub in tool.flyout"
:key="sub"
@ -101,11 +112,17 @@ function activeKeyForTool() {
>
<DropdownMenuItem
v-test-id="toolbarFlyoutItemTestId(sub, mobile)"
:class="menu().item({ class: subActive ? 'bg-accent text-white' : undefined })"
:data-active="subActive || undefined"
:class="flyoutItemClass(subActive)"
@select="actions.select"
>
<component :is="toolIcons[sub]" class="size-3.5" />
<span class="flex-1">{{ toolLabels[sub] }}</span>
<component
:is="toolIcons[sub]"
:class="styles.flyoutItemIcon({ class: ui?.flyoutItemIcon })"
/>
<span :class="styles.flyoutItemLabel({ class: ui?.flyoutItemLabel })">
{{ toolLabels[sub] }}
</span>
<AppShortcutText v-if="!mobile && toolShortcuts[sub]">
{{ toolShortcuts[sub] }}
</AppShortcutText>

View file

@ -1,13 +1,19 @@
<script setup lang="ts">
import { tv } from 'tailwind-variants'
import toolbarTheme from '@/theme/toolbar'
import { vTestId } from '@open-pencil/vue'
import type { ToolbarActionItem } from '@/components/Toolbar/types'
import type { ToolbarActionItem, ToolbarUI } from '@/components/Toolbar/types'
const { actions, testPrefix } = defineProps<{
const { actions, testPrefix, ui } = defineProps<{
actions: ToolbarActionItem[]
testPrefix: string
ui?: ToolbarUI
}>()
const styles = tv(toolbarTheme)()
const emit = defineEmits<{
action: [item: ToolbarActionItem]
}>()
@ -18,9 +24,9 @@ const emit = defineEmits<{
v-for="item in actions"
:key="item.label"
v-test-id="`${testPrefix}-${item.label.toLowerCase()}`"
class="flex size-8 cursor-pointer items-center justify-center rounded-[6px] border-none bg-transparent text-muted transition-colors select-none active:bg-hover active:text-surface"
:class="styles.action({ class: ui?.action })"
@click="emit('action', item)"
>
<component :is="item.icon" class="size-4" />
<component :is="item.icon" :class="styles.actionIcon({ class: ui?.actionIcon })" />
</button>
</template>

View file

@ -2,15 +2,16 @@ import type { Component } from 'vue'
import type { Tool } from '@open-pencil/vue'
import type { ComponentUI } from '@/components/ui/types'
import type { ToolbarTheme } from '@/theme/toolbar'
export interface ToolbarActionItem {
icon: Component
label: string
action: () => void
}
export interface ToolbarUI {
flyoutContent?: string
}
export type ToolbarUI = ComponentUI<ToolbarTheme>
export type ToolLabels = Record<Tool, string>
export type ToolIconMap = Record<Tool, Component>

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

@ -1,5 +1,5 @@
<script setup lang="ts">
import { twMerge } from 'tailwind-merge'
import { tv } from 'tailwind-variants'
import { PopoverContent, PopoverPortal, PopoverRoot, PopoverTrigger } from 'reka-ui'
import { applySolidFillColor, FillRoot, useI18n } from '@open-pencil/vue'
@ -10,18 +10,15 @@ import ImageFillPicker from '@/components/fill-picker/ImageFillPicker.vue'
import FillSwatch from '@/components/ui/FillSwatch.vue'
import Tip from '@/components/ui/Tip.vue'
import { usePopoverUI } from '@/components/ui/popover'
import fillPickerTheme from '@/theme/fill-picker'
import type { Fill } from '@open-pencil/scene-graph'
import type { OkHCLControls } from '@open-pencil/vue'
const TAB_BASE =
'flex size-6 cursor-pointer items-center justify-center rounded border-none p-0 transition-colors'
const fillPicker = tv(fillPickerTheme)
function tabClass(active: boolean) {
return twMerge(
TAB_BASE,
active ? 'bg-hover text-surface' : 'text-muted hover:bg-hover hover:text-surface'
)
return fillPicker({ active }).tab()
}
const {
@ -77,6 +74,7 @@ function cancelFromEscape(event: KeyboardEvent) {
<div class="mb-2 flex items-center gap-0.5">
<Tip :label="panels.solid">
<button
:data-active="root.category === 'SOLID' || undefined"
:class="tabClass(root.category === 'SOLID')"
data-test-id="fill-picker-tab-solid"
@click="root.actions.toSolid"
@ -86,6 +84,7 @@ function cancelFromEscape(event: KeyboardEvent) {
</Tip>
<Tip :label="panels.linearGradient">
<button
:data-active="root.category === 'GRADIENT' || undefined"
:class="tabClass(root.category === 'GRADIENT')"
data-test-id="fill-picker-tab-gradient"
@click="root.actions.toGradient"
@ -95,6 +94,7 @@ function cancelFromEscape(event: KeyboardEvent) {
</Tip>
<Tip :label="panels.image">
<button
:data-active="root.category === 'IMAGE' || undefined"
:class="tabClass(root.category === 'IMAGE')"
data-test-id="fill-picker-tab-image"
@click="root.actions.toImage"

View file

@ -1,8 +1,11 @@
<script setup lang="ts">
import { tv } from 'tailwind-variants'
import AppSelect from '@/components/ui/AppSelect.vue'
import Tip from '@/components/ui/Tip.vue'
import ColorPickerPanel from '@/components/color-picker-panel/ColorPickerPanel.vue'
import NumberField from '@/components/inputs/NumberField.vue'
import fillPickerTheme from '@/theme/fill-picker'
import { colorToCSS } from '@open-pencil/core/color'
import {
GradientEditorRoot,
@ -17,6 +20,15 @@ import type { Fill } from '@open-pencil/scene-graph'
const { fill } = defineProps<{ fill: Fill }>()
const emit = defineEmits<{ update: [fill: Fill] }>()
const { panels } = useI18n()
const fillPicker = tv(fillPickerTheme)
function barStopClass(active: boolean, dragging: boolean) {
return fillPicker({ active, dragging }).barStop()
}
function listStopClass(active: boolean) {
return fillPicker({ active }).listStop()
}
</script>
<template>
@ -48,8 +60,7 @@ const { panels } = useI18n()
:active="idx === bar.activeStopIndex"
:dragging="idx === bar.draggingIndex"
:removable="bar.stops.length > 2"
class="absolute top-1/2 size-3.5 -translate-x-1/2 -translate-y-1/2 cursor-grab rounded-sm border-2 shadow-sm data-[selected]:border-white data-[dragging]:cursor-grabbing"
:class="idx === bar.activeStopIndex ? 'border-white' : 'border-white/60'"
:class="barStopClass(idx === bar.activeStopIndex, idx === bar.draggingIndex)"
:style="{ left: `${stop.position * 100}%`, background: colorToCSS(stop.color) }"
@select="root.actions.selectStop"
@update-position="root.actions.updateStopPosition"
@ -79,7 +90,7 @@ const { panels } = useI18n()
:active="idx === root.activeStopIndex"
:removable="root.stops.length > 2"
:interactive="false"
class="flex items-center gap-1 py-0.5 data-[selected]:rounded data-[selected]:bg-hover/50"
:class="listStopClass(idx === root.activeStopIndex)"
@select="root.actions.selectStop"
@update-position="root.actions.updateStopPosition"
@update-color="root.actions.updateStopColor"

View file

@ -1,5 +1,6 @@
<script setup lang="ts">
import { ref } from 'vue'
import { tv } from 'tailwind-variants'
import {
SelectContent,
SelectItem,
@ -12,6 +13,7 @@ import {
} from 'reka-ui'
import AppSelect from '@/components/ui/AppSelect.vue'
import layoutAlignmentTheme from '@/theme/layout-alignment'
import VariableNumberField from '@/components/properties/VariableNumberField.vue'
import ClipContentControl from '@/components/properties/LayoutSection/ClipContentControl.vue'
@ -22,6 +24,8 @@ import { useI18n, useLayoutControlsContext } from '@open-pencil/vue'
import type { LayoutDirection, LayoutAlign } from '@open-pencil/scene-graph'
const ctx = useLayoutControlsContext()
const layoutAlignment = tv(layoutAlignmentTheme)
const alignmentStyles = layoutAlignment()
const gapFieldRef = ref<HTMLElement | null>(null)
const { panels } = useI18n()
@ -40,6 +44,10 @@ function isAlignmentActive(primary: LayoutAlign, counter: string) {
return ctx.node.primaryAxisAlign === 'SPACE_BETWEEN' && ctx.node.counterAxisAlign === counter
return ctx.node.primaryAxisAlign === primary && ctx.node.counterAxisAlign === counter
}
function alignmentCellClass(primary: LayoutAlign, counter: string) {
return layoutAlignment({ active: isAlignmentActive(primary, counter) }).cell()
}
</script>
<template>
@ -217,19 +225,15 @@ function isAlignmentActive(primary: LayoutAlign, counter: string) {
<div class="mt-2">
<label class="mb-1 block text-[11px] text-muted">{{ panels.alignment }}</label>
<div data-test-id="layout-alignment-grid" class="grid w-fit grid-cols-3 gap-0.5">
<div data-test-id="layout-alignment-grid" :class="alignmentStyles.grid()">
<button
v-for="cell in ctx.alignGrid"
:key="`${cell.primary}-${cell.counter}`"
class="flex size-6 cursor-pointer items-center justify-center rounded border text-[11px]"
:class="
isAlignmentActive(cell.primary, cell.counter)
? 'border-accent bg-accent/10 text-accent'
: 'border-border text-muted hover:bg-hover hover:text-surface'
"
:data-active="isAlignmentActive(cell.primary, cell.counter) || undefined"
:class="alignmentCellClass(cell.primary, cell.counter)"
@click="ctx.setAlignment(ctx.gapAuto ? 'SPACE_BETWEEN' : cell.primary, cell.counter)"
>
<span class="size-1.5 rounded-full bg-current" />
<span :class="alignmentStyles.dot()" />
</button>
</div>
</div>

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

31
src/theme/fill-picker.ts Normal file
View file

@ -0,0 +1,31 @@
const fillPickerTheme = {
slots: {
tab: 'flex size-6 cursor-pointer items-center justify-center rounded border-none p-0 text-muted transition-colors outline-none focus-visible:ring-1 focus-visible:ring-accent',
barStop:
'absolute top-1/2 size-3.5 -translate-x-1/2 -translate-y-1/2 cursor-grab rounded-sm border-2 border-white/60 shadow-sm outline-none data-[dragging]:cursor-grabbing focus-visible:ring-1 focus-visible:ring-accent',
listStop: 'flex items-center gap-1 py-0.5'
},
variants: {
active: {
true: {
tab: 'bg-hover text-surface',
barStop: 'border-white',
listStop: 'rounded bg-hover/50'
},
false: {
tab: 'hover:bg-hover hover:text-surface'
}
},
dragging: {
true: { barStop: 'cursor-grabbing' },
false: {}
}
},
defaultVariants: {
active: false,
dragging: false
}
}
export type FillPickerTheme = typeof fillPickerTheme
export default fillPickerTheme

View file

@ -0,0 +1,24 @@
const layoutAlignmentTheme = {
slots: {
grid: 'grid w-fit grid-cols-3 gap-0.5',
cell: 'flex size-6 cursor-pointer items-center justify-center rounded border text-[11px]',
dot: 'size-1.5 rounded-full bg-current'
},
variants: {
active: {
true: { cell: 'border-accent bg-accent/10 text-accent' },
false: { cell: 'border-border text-muted hover:bg-hover hover:text-surface' }
},
disabled: {
true: { cell: 'pointer-events-none opacity-50' },
false: {}
}
},
defaultVariants: {
active: false,
disabled: false
}
}
export type LayoutAlignmentTheme = typeof layoutAlignmentTheme
export default layoutAlignmentTheme

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

79
src/theme/toolbar.ts Normal file
View file

@ -0,0 +1,79 @@
const toolbarTheme = {
slots: {
button:
'flex size-8 cursor-pointer items-center justify-center border-none bg-transparent text-muted transition-colors outline-none focus-visible:ring-1 focus-visible:ring-accent',
icon: 'size-4',
flyoutGroup: 'flex items-center',
flyoutTrigger:
'flex h-8 w-3 cursor-pointer items-center justify-center border-none bg-transparent text-muted transition-colors outline-none focus-visible:ring-1 focus-visible:ring-accent',
flyoutTriggerIcon: 'size-2.5',
flyoutContent: '',
flyoutItem: '',
flyoutItemIcon: 'size-3.5',
flyoutItemLabel: 'flex-1',
navigationAction:
'flex size-7 shrink-0 cursor-pointer items-center justify-center rounded-full border border-border bg-panel text-muted shadow-sm outline-none select-none focus-visible:ring-1 focus-visible:ring-accent disabled:pointer-events-none',
navigationIcon: 'size-3.5',
action:
'flex size-8 cursor-pointer items-center justify-center rounded-[6px] border-none bg-transparent text-muted transition-colors outline-none select-none active:bg-hover active:text-surface focus-visible:ring-1 focus-visible:ring-accent',
actionIcon: 'size-4'
},
variants: {
active: {
true: {
button: 'bg-accent text-white',
flyoutTrigger: 'bg-accent text-white'
},
false: {}
},
mobile: {
true: {
button: 'rounded-[6px] select-none',
flyoutTrigger: 'rounded-[6px] select-none'
},
false: {
button: 'rounded-lg',
flyoutTrigger: 'rounded-lg'
}
},
disabled: {
true: {
navigationAction: 'pointer-events-none'
},
false: {}
},
subActive: {
true: {
flyoutItem: 'bg-accent text-white'
},
false: {}
}
},
compoundVariants: [
{
active: false,
mobile: true,
class: {
button: 'active:bg-hover',
flyoutTrigger: 'active:bg-hover'
}
},
{
active: false,
mobile: false,
class: {
button: 'hover:bg-hover hover:text-surface',
flyoutTrigger: 'hover:bg-hover hover:text-surface'
}
}
],
defaultVariants: {
active: false,
mobile: false,
disabled: false,
subActive: false
}
}
export type ToolbarTheme = typeof toolbarTheme
export default toolbarTheme

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

@ -158,7 +158,9 @@ test('hsb saturation and brightness sliders both affect fill color', async () =>
test('gradient stops support keyboard nudging and removal', async () => {
await openFillPicker()
await page.getByTestId('fill-picker-tab-gradient').click()
const gradientTab = page.getByTestId('fill-picker-tab-gradient')
await gradientTab.click()
await expect(gradientTab).toHaveAttribute('data-active', 'true')
await page.getByTestId('fill-picker-add-stop').click()
const stops = page.getByTestId('fill-picker-gradient-bar').getByRole('slider')

View file

@ -256,6 +256,7 @@ test('alignment grid center sets CENTER alignment', async () => {
const centerCell = page.getByTestId('layout-alignment-grid').locator('button').nth(4)
await centerCell.click()
await canvas.waitForRender()
await expect(centerCell).toHaveAttribute('data-active', 'true')
const frame = await getNodeById(page, frameId)
expect(expectDefined(frame, 'frame').primaryAxisAlign).toBe('CENTER')

View file

@ -1,6 +1,10 @@
import { expect, test, useEditorSetup } from '#tests/e2e/fixtures'
import { getPageChildren } from '#tests/helpers/store'
import { toolbarFlyoutItemTestId, toolbarFlyoutTestId } from '#tests/helpers/test-ids'
import {
toolbarFlyoutItemTestId,
toolbarFlyoutTestId,
toolbarToolTestId
} from '#tests/helpers/test-ids'
const editor = useEditorSetup()
@ -12,6 +16,10 @@ test('shapes flyout opens', async () => {
test('Polygon tool creates POLYGON node', async () => {
await editor.page.getByTestId(toolbarFlyoutItemTestId('POLYGON')).click()
await expect(editor.page.getByTestId(toolbarToolTestId('POLYGON'))).toHaveAttribute(
'data-active',
'true'
)
await editor.canvas.drag(300, 200, 400, 300)
await editor.canvas.waitForRender()

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,23 +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/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<string, unknown>
type ExpressionNode = UnknownRecord & { type: string }
type VueTemplateNode = {
@ -119,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,9 +42,11 @@ describe('dynamic Tailwind state classes', () => {
expect(diagnostics).toHaveLength(1)
})
test('allows only audited locations inside legacy files', () => {
const audited = `<template>${'\n'.repeat(19)}<button :class="active ? 'bg-hover' : 'text-muted'" /></template>`
expect(dynamicClassDiagnostics('src/components/Toolbar/ToolButton.vue', audited)).toEqual([])
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)
).toHaveLength(1)
expect(
dynamicClassDiagnostics(
'src/components/LayersPanel.vue',