refactor(ui): unify full-area placeholders

- Add one themed placeholder primitive for page, panel, dialog, and setup states

- Vertically center chat, code, assets, variables, and storage placeholders

- Let Vue replace the static boot splash instead of mutating global DOM from views
This commit is contained in:
Danila Poyarkov 2026-07-26 16:41:02 +03:00
parent 050978029b
commit a22d1edf8b
13 changed files with 242 additions and 85 deletions

View file

@ -29,7 +29,7 @@
--loader-fg: 32, 33, 36;
}
body { margin: 0; background: var(--loader-bg); }
#loader {
#boot-splash {
position: fixed;
inset: 0;
display: flex;
@ -38,11 +38,9 @@
z-index: 9999;
color: rgb(var(--loader-fg));
background: var(--loader-bg);
transition: opacity 0.3s;
}
#loader.fade-out { opacity: 0; pointer-events: none; }
#loader svg { width: 32px; height: 32px; opacity: 0.45; }
#loader .bar {
#boot-splash svg { width: 32px; height: 32px; opacity: 0.45; }
#boot-splash .bar {
position: absolute;
bottom: 50%;
left: 50%;
@ -53,7 +51,7 @@
border-radius: 1px;
overflow: hidden;
}
#loader .bar::after {
#boot-splash .bar::after {
content: '';
display: block;
height: 100%;
@ -70,13 +68,14 @@
</head>
<body>
<div id="loader">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round">
<path d="m15.232 5.232 3.536 3.536m-2.036-5.036a2.5 2.5 0 0 1 3.536 3.536L6.5 21.036H3v-3.572L16.732 3.732Z"/>
</svg>
<div class="bar"></div>
<div id="app">
<div id="boot-splash">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round">
<path d="m15.232 5.232 3.536 3.536m-2.036-5.036a2.5 2.5 0 0 1 3.536 3.536L6.5 21.036H3v-3.572L16.732 3.732Z"/>
</svg>
<div class="bar"></div>
</div>
</div>
<div id="app"></div>
<script type="module" src="/src/main.ts"></script>
</body>
</html>

View file

@ -1,8 +0,0 @@
const LOADER_FADE_OUT_MS = 300
export function fadeOutGlobalLoader() {
const loader = document.getElementById('loader')
if (!loader) return
loader.classList.add('fade-out')
setTimeout(() => loader.remove(), LOADER_FADE_OUT_MS)
}

View file

@ -10,6 +10,7 @@ import { activeTab } from '@/app/tabs'
import AcpPermissionDialog from '@/components/chat/AcpPermissionDialog.vue'
import ChatInput from '@/components/chat/ChatInput.vue'
import ChatMessage from '@/components/chat/ChatMessage.vue'
import AppPlaceholder from '@/components/ui/AppPlaceholder.vue'
import AppTextButton from '@/components/ui/AppTextButton.vue'
import ProviderSetup from '@/components/chat/ProviderSetup.vue'
import { useAIChat } from '@/app/ai/chat/use'
@ -132,15 +133,16 @@ function handleClearChat() {
<template v-else>
<ScrollAreaRoot class="min-h-0 flex-1">
<ScrollAreaViewport class="h-full px-3 py-3 [&>div]:h-full">
<!-- Empty state -->
<div
<AppPlaceholder
v-if="messages.length === 0"
data-test-id="chat-empty-state"
class="flex h-full flex-col items-center justify-center gap-3 text-muted"
:label="dialogs.describeCreateOrChange"
:ui="{ root: 'h-full' }"
>
<icon-lucide-message-circle class="size-8 opacity-50" />
<p class="text-center text-xs">{{ dialogs.describeCreateOrChange }}</p>
</div>
<template #icon>
<icon-lucide-message-circle class="size-5" />
</template>
</AppPlaceholder>
<!-- Messages -->
<div v-else data-test-id="chat-messages" class="flex flex-col gap-3">

View file

@ -9,6 +9,7 @@ import { JSX_REFERENCE, selectionToJSX } from '@open-pencil/core/design-jsx'
import { useI18n, useSceneComputed } from '@open-pencil/vue'
import { useEditorStore } from '@/app/editor/active-store'
import AppPlaceholder from '@/components/ui/AppPlaceholder.vue'
import AppTextButton from '@/components/ui/AppTextButton.vue'
import Tip from '@/components/ui/Tip.vue'
@ -201,13 +202,15 @@ function copyReference() {
</div>
</div>
<div
<AppPlaceholder
v-if="!jsxCode"
data-test-id="code-panel-empty"
class="flex flex-1 items-center justify-center px-4 text-center"
:label="dialogs.selectLayerForJSX"
>
<span class="text-xs text-muted">{{ dialogs.selectLayerForJSX }}</span>
</div>
<template #icon>
<icon-lucide-code-2 class="size-5" />
</template>
</AppPlaceholder>
<ScrollAreaRoot v-else data-test-id="code-panel" class="min-h-0 flex-1">
<ScrollAreaViewport class="code-highlight size-full">

View file

@ -25,7 +25,6 @@ import { useCollabInjected } from '@/app/collab/use'
import { useEditorStore } from '@/app/editor/active-store'
import { useCanvasCollaborationAwareness } from '@/app/editor/canvas/collaboration-awareness'
import { createCanvasContextSelection } from '@/app/editor/canvas/context-selection'
import { fadeOutGlobalLoader } from '@/app/editor/canvas/loader-overlay'
import IconLucidePanelBottom from '~icons/lucide/panel-bottom'
import IconLucidePanelLeft from '~icons/lucide/panel-left'
import IconLucidePanelRight from '~icons/lucide/panel-right'
@ -43,8 +42,7 @@ const { selectAtContextPoint } = createCanvasContextSelection(canvasRef, store)
useCanvas(sceneCanvasRef, store, {
layer: 'scene',
showRulers: false,
onReady: fadeOutGlobalLoader
showRulers: false
})
const { hitTestSectionTitle, hitTestComponentLabel, hitTestFrameTitle } = useCanvas(
canvasRef,

View file

@ -20,6 +20,7 @@ import { openExternalLink } from '@/app/shell/ui'
import AssetThumbnail from '@/components/assets-panel/AssetThumbnail.vue'
import { findAssetPage } from '@/components/assets-panel/page'
import AppInput from '@/components/ui/AppInput.vue'
import AppPlaceholder from '@/components/ui/AppPlaceholder.vue'
import { useButtonUI } from '@/components/ui/button'
import { AppDialogRoot, useDialogUI } from '@/components/ui/dialog'
import { useMenuUI } from '@/components/ui/menu'
@ -272,7 +273,7 @@ function insertSelectedAsset() {
</SegmentedControl>
</div>
<div class="scrollbar-thin flex-1 overflow-y-auto px-2 pb-2">
<div class="scrollbar-thin flex min-h-0 flex-1 flex-col overflow-y-auto px-2 pb-2">
<section v-for="group in assetGroups" :key="group.pageId" class="mb-3">
<h2 class="mb-1 px-1 text-[10px] font-medium tracking-wide text-muted uppercase">
{{ group.pageName }}
@ -396,13 +397,16 @@ function insertSelectedAsset() {
</div>
</section>
<div
<AppPlaceholder
v-if="filteredAssets.length === 0"
data-test-id="assets-empty"
class="px-3 py-6 text-center text-xs text-muted"
:label="panels.noLocalComponents"
size="compact"
>
{{ panels.noLocalComponents }}
</div>
<template #icon>
<icon-lucide-component class="size-4" />
</template>
</AppPlaceholder>
</div>
<AppDialogRoot

View file

@ -2,21 +2,25 @@
import { useI18n } from '@open-pencil/vue'
import { openSettingsDialog } from '@/app/settings/dialog'
import AppPlaceholder from '@/components/ui/AppPlaceholder.vue'
const { dialogs } = useI18n()
</script>
<template>
<div data-test-id="provider-setup" class="flex flex-1 flex-col items-center justify-center px-6">
<icon-lucide-sparkles class="mb-3 size-7 text-muted" />
<p class="mb-5 text-center text-xs text-muted">{{ dialogs.connectAIProvider }}</p>
<button
type="button"
data-test-id="provider-setup-open-settings"
class="w-full rounded bg-accent py-1.5 text-xs font-medium text-white hover:bg-accent/90"
@click="openSettingsDialog('ai')"
>
{{ dialogs.openProviderSettings }}
</button>
</div>
<AppPlaceholder data-test-id="provider-setup" :label="dialogs.connectAIProvider">
<template #icon>
<icon-lucide-sparkles class="size-5" />
</template>
<template #action>
<button
type="button"
data-test-id="provider-setup-open-settings"
class="w-full rounded bg-accent py-1.5 text-xs font-medium text-white hover:bg-accent/90"
@click="openSettingsDialog('ai')"
>
{{ dialogs.openProviderSettings }}
</button>
</template>
</AppPlaceholder>
</template>

View file

@ -11,6 +11,7 @@ import {
} from 'reka-ui'
import AppBadge from '@/components/ui/AppBadge.vue'
import AppPlaceholder from '@/components/ui/AppPlaceholder.vue'
import { useInputUI } from '@/components/ui/input'
import { useSelectUI } from '@/components/ui/select'
@ -23,6 +24,7 @@ export type AppComboboxOption = {
interface AppComboboxInputProps {
options: AppComboboxOption[]
placeholder?: string
emptyLabel?: string
ui?: {
input?: string
content?: string
@ -34,7 +36,7 @@ interface AppComboboxInputProps {
defineOptions({ inheritAttrs: false })
const { options, placeholder, ui } = defineProps<AppComboboxInputProps>()
const { options, placeholder, emptyLabel = 'No results', ui } = defineProps<AppComboboxInputProps>()
const modelValue = defineModel<string>({ required: true })
const open = ref(false)
@ -45,7 +47,6 @@ const select = useSelectUI({
})
const inputClass = computed(() => useInputUI({ size: 'sm', ui: { base: ui?.input } }).base)
const viewportClass = ui?.viewport ?? 'max-h-56 overflow-y-auto p-0.5'
const emptyClass = ui?.empty ?? 'px-2 py-2 text-[11px] text-muted'
const filteredOptions = computed(() => {
const query = modelValue.value.trim().toLowerCase()
@ -107,7 +108,13 @@ function updateValue(value: AcceptableValue) {
</div>
<AppBadge v-if="option.meta">{{ option.meta }}</AppBadge>
</ComboboxItem>
<div v-if="filteredOptions.length === 0" :class="emptyClass">No matching models</div>
<AppPlaceholder
v-if="filteredOptions.length === 0"
:label="emptyLabel"
:fill="false"
size="compact"
:ui="{ root: ui?.empty }"
/>
</ComboboxViewport>
</ComboboxContent>
</ComboboxPortal>

View file

@ -0,0 +1,51 @@
import type { Meta, StoryObj } from '@storybook/vue3-vite'
import AppPlaceholder from './AppPlaceholder.vue'
type PlaceholderStoryArgs = {
label: string
description?: string
fill?: boolean
size: 'compact' | 'panel' | 'page'
}
const meta = {
title: 'Design System/Placeholder',
args: {
label: 'No documents yet',
description: 'Create a document to start working in this space.',
size: 'panel'
},
render: (args) => ({
components: { AppPlaceholder },
setup: () => ({ args }),
template: `
<div class="flex h-72 w-full bg-app text-surface">
<AppPlaceholder v-bind="args">
<template #icon>
<icon-lucide-files class="size-5" />
</template>
<template #action>
<button type="button" class="rounded bg-accent px-3 py-1.5 text-xs font-medium text-white">
Create document
</button>
</template>
</AppPlaceholder>
</div>
`
})
} satisfies Meta<PlaceholderStoryArgs>
export default meta
type Story = StoryObj<typeof meta>
export const Panel: Story = {}
export const Compact: Story = {
args: {
label: 'No results',
description: undefined,
fill: false,
size: 'compact'
}
}

View file

@ -0,0 +1,55 @@
<script setup lang="ts">
import { computed, useSlots } from 'vue'
import { tv, type VariantProps } from 'tailwind-variants'
import type { ComponentUI } from '@/components/ui/types'
import placeholderTheme from '@/theme/placeholder'
const placeholder = tv(placeholderTheme)
type PlaceholderVariants = VariantProps<typeof placeholder>
type PlaceholderUI = ComponentUI<typeof placeholderTheme>
const {
label,
description,
fill = true,
size = 'panel',
ui
} = defineProps<{
label: string
description?: string
fill?: boolean
size?: PlaceholderVariants['size']
ui?: PlaceholderUI
}>()
const slots = useSlots()
const styles = computed(() => {
const theme = placeholder({ fill, size })
return {
root: theme.root({ class: ui?.root }),
content: theme.content({ class: ui?.content }),
icon: theme.icon({ class: ui?.icon }),
label: theme.label({ class: ui?.label }),
description: theme.description({ class: ui?.description }),
action: theme.action({ class: ui?.action })
}
})
</script>
<template>
<div :class="styles.root" data-slot="placeholder">
<div :class="styles.content" data-slot="placeholder-content">
<div v-if="slots.icon" :class="styles.icon" data-slot="placeholder-icon" aria-hidden="true">
<slot name="icon" />
</div>
<p :class="styles.label" data-slot="placeholder-label">{{ label }}</p>
<p v-if="description" :class="styles.description" data-slot="placeholder-description">
{{ description }}
</p>
<div v-if="slots.action" :class="styles.action" data-slot="placeholder-action">
<slot name="action" />
</div>
</div>
</div>
</template>

View file

@ -32,6 +32,7 @@ import IconToggleLeft from '~icons/lucide/toggle-left'
import IconType from '~icons/lucide/type'
import IconX from '~icons/lucide/x'
import ColorInput from '@/components/ColorPicker/ColorInput.vue'
import AppPlaceholder from '@/components/ui/AppPlaceholder.vue'
import Tip from '@/components/ui/Tip.vue'
import { AppDialogRoot } from '@/components/ui/dialog'
import { useMenuUI } from '@/components/ui/menu'
@ -133,18 +134,20 @@ function resizeHandleClass(resizing: boolean) {
<icon-lucide-x class="size-4" />
</DialogClose>
</div>
<div class="flex flex-1 items-center justify-center">
<div class="text-center">
<p class="text-sm text-muted">{{ dialogs.noVariableCollections }}</p>
<AppPlaceholder :label="dialogs.noVariableCollections">
<template #icon>
<icon-lucide-folder class="size-5" />
</template>
<template #action>
<button
data-test-id="variables-create-collection"
class="mt-2 cursor-pointer rounded bg-hover px-3 py-1.5 text-xs text-surface hover:bg-border"
class="cursor-pointer rounded bg-hover px-3 py-1.5 text-xs text-surface hover:bg-border"
@click="ctx.addCollection"
>
{{ dialogs.createCollection }}
</button>
</div>
</div>
</template>
</AppPlaceholder>
</div>
<template v-else>

35
src/theme/placeholder.ts Normal file
View file

@ -0,0 +1,35 @@
const placeholderTheme = {
slots: {
root: 'flex min-h-0 w-full items-center justify-center text-center',
content: 'flex max-w-sm flex-col items-center',
icon: 'mb-3 flex size-10 items-center justify-center rounded-full bg-panel-field text-muted',
label: 'text-xs font-medium text-surface',
description: 'mt-1 text-xs leading-relaxed text-muted',
action: 'mt-4 flex w-full items-center justify-center gap-2'
},
variants: {
fill: {
true: { root: 'flex-1' },
false: {}
},
size: {
compact: {
root: 'px-3 py-4',
content: 'max-w-xs',
icon: 'mb-2 size-8',
label: 'font-normal text-muted',
description: 'text-[11px]',
action: 'mt-3'
},
panel: { root: 'p-6' },
page: { root: 'p-8' }
}
},
defaultVariants: {
fill: true,
size: 'panel' as const
}
} as const
export type PlaceholderTheme = typeof placeholderTheme
export default placeholderTheme

View file

@ -11,10 +11,10 @@ import {
storageProviderRegistry,
type StorageDocument
} from '@/app/integrations/storage'
import { fadeOutGlobalLoader } from '@/app/editor/canvas/loader-overlay'
import { openSettingsDialog, settingsDialogOpen } from '@/app/settings/dialog'
import type { CredentialStatus } from '@/app/settings/credentials/types'
import { createCanvasId } from '@/app/storage/id'
import AppPlaceholder from '@/components/ui/AppPlaceholder.vue'
import { getLocalCanvasStore } from '@/app/storage/local-store'
import { activeTab, createTab, openStorageDocumentInNewTab } from '@/app/tabs'
@ -117,13 +117,12 @@ watch(settingsDialogOpen, (open, wasOpen) => {
})
onMounted(() => {
fadeOutGlobalLoader()
void refresh()
})
</script>
<template>
<main class="min-h-screen bg-app text-surface" data-test-id="storage-workspace">
<main class="flex min-h-screen flex-col bg-app text-surface" data-test-id="storage-workspace">
<header class="flex h-14 items-center border-b border-border px-6">
<div>
<h1 class="text-sm font-semibold">{{ dialogs.storageWorkspace }}</h1>
@ -149,10 +148,9 @@ onMounted(() => {
</div>
</header>
<section class="mx-auto max-w-6xl p-6">
<div class="mb-4 flex items-center justify-between">
<p v-if="loading" class="text-xs text-muted">{{ dialogs.loadingDocuments }}</p>
<p v-else-if="error && configured" class="text-xs text-danger" role="alert">
<section class="mx-auto flex min-h-0 w-full max-w-6xl flex-1 flex-col p-6">
<div class="mb-4 flex shrink-0 items-center justify-between">
<p v-if="error && configured" class="text-xs text-danger" role="alert">
{{ error }}
</p>
<span v-else />
@ -188,26 +186,32 @@ onMounted(() => {
</button>
</div>
<div v-else-if="!loading && configured" class="py-24 text-center text-xs text-muted">
{{ dialogs.emptyStorageWorkspace }}
</div>
<AppPlaceholder v-else-if="loading" :label="dialogs.loadingDocuments" size="page">
<template #icon>
<icon-lucide-loader-circle class="size-5 animate-spin" />
</template>
</AppPlaceholder>
<div
v-else-if="!loading"
class="mx-auto flex max-w-sm flex-col items-center py-24 text-center"
>
<div class="mb-3 flex size-10 items-center justify-center rounded-full bg-panel-field">
<icon-lucide-cloud class="size-5 text-muted" />
</div>
<p class="text-xs text-surface">{{ dialogs.storageNotConfigured }}</p>
<button
type="button"
class="mt-4 rounded bg-accent px-3 py-1.5 text-xs font-medium text-white hover:bg-accent/90"
@click="openSettingsDialog('storage')"
>
{{ dialogs.settings }}
</button>
</div>
<AppPlaceholder v-else-if="configured" :label="dialogs.emptyStorageWorkspace" size="page">
<template #icon>
<icon-lucide-files class="size-5" />
</template>
</AppPlaceholder>
<AppPlaceholder v-else :label="dialogs.storageNotConfigured" size="page">
<template #icon>
<icon-lucide-cloud class="size-5" />
</template>
<template #action>
<button
type="button"
class="rounded bg-accent px-3 py-1.5 text-xs font-medium text-white hover:bg-accent/90"
@click="openSettingsDialog('storage')"
>
{{ dialogs.settings }}
</button>
</template>
</AppPlaceholder>
</section>
</main>
</template>