Merge branch 'open-pencil:master' into master
This commit is contained in:
commit
d78f4bb64e
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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 }}
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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
|
||||
31
src/theme/fill-picker.ts
Normal file
31
src/theme/fill-picker.ts
Normal 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
|
||||
24
src/theme/layout-alignment.ts
Normal file
24
src/theme/layout-alignment.ts
Normal 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
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
|
||||
79
src/theme/toolbar.ts
Normal file
79
src/theme/toolbar.ts
Normal 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
|
||||
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()
|
||||
|
|
|
|||
|
|
@ -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')
|
||||
|
|
|
|||
|
|
@ -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')
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
||||
|
|
|
|||
|
|
@ -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,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.',
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
Loading…
Reference in a new issue