From 4733a59f263f2287cd8cd29e49bdd37ee52cfca6 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Thu, 16 Jul 2026 09:02:32 -0700 Subject: [PATCH] =?UTF-8?q?feat(ui):=20web=20delights=20=E2=80=94=20empty-?= =?UTF-8?q?state=20mascots,=20first-reply=20confetti,=20composer=20drag-ca?= =?UTF-8?q?tch=20(#109118)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(ui): web delights — empty-state mascots, first-reply confetti, composer drag-catch Three mascot-powered moments in the Control UI: the plugins page and channels hub show a sleepy Clawd when truly empty and a curious one on a search miss; the very first successful assistant reply in a browser earns a single confetti burst in the mascot palette (localStorage once-flag, reduced-motion aware); and dragging files over the new- session composer makes the welcome mascot tease with an open mouth, then snap a catch on drop — upload behavior untouched. * fix(ui): satisfy strict types and knip for the confetti module Tuple index under noUncheckedIndexedAccess gets an anchored fallback, the once-flag helper stays module-internal (knip counts only production consumers), and its coverage moves to the public fireFirstReplyConfetti path in jsdom using the shared storage mock. --- ui/src/components/confetti.test.ts | 69 +++++++++++ ui/src/components/confetti.ts | 115 +++++++++++++++++++ ui/src/components/mascot-animator.test.ts | 30 +++++ ui/src/components/mascot-animator.ts | 30 +++++ ui/src/components/openclaw-mascot.ts | 17 ++- ui/src/pages/channels/view.ts | 9 +- ui/src/pages/chat/chat-state.ts | 41 ++++++- ui/src/pages/chat/chat-view.test.ts | 18 +++ ui/src/pages/chat/components/chat-welcome.ts | 42 ++++++- ui/src/pages/plugins/view.ts | 28 +++-- ui/src/styles/channels.css | 10 ++ ui/src/styles/plugins.css | 6 +- 12 files changed, 401 insertions(+), 14 deletions(-) create mode 100644 ui/src/components/confetti.test.ts create mode 100644 ui/src/components/confetti.ts diff --git a/ui/src/components/confetti.test.ts b/ui/src/components/confetti.test.ts new file mode 100644 index 000000000000..edc2e1450b3d --- /dev/null +++ b/ui/src/components/confetti.test.ts @@ -0,0 +1,69 @@ +// @vitest-environment jsdom +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { createStorageMock } from "../test-helpers/storage.ts"; +import { fireFirstReplyConfetti } from "./confetti.ts"; + +const FLAG_KEY = "openclaw.confetti.firstReply"; + +function stubMatchMedia(reducedMotion: boolean): void { + vi.stubGlobal( + "matchMedia", + (query: string) => + ({ + matches: reducedMotion && query.includes("prefers-reduced-motion"), + media: query, + addEventListener: () => undefined, + removeEventListener: () => undefined, + }) as unknown as MediaQueryList, + ); +} + +describe("fireFirstReplyConfetti", () => { + let storage: Storage; + + beforeEach(() => { + storage = createStorageMock(); + vi.stubGlobal("localStorage", storage); + stubMatchMedia(false); + // jsdom canvases have no 2d context; stub a minimal drawing surface so the + // burst path runs deterministically. + vi.spyOn(HTMLCanvasElement.prototype, "getContext").mockReturnValue({ + setTransform: () => undefined, + clearRect: () => undefined, + save: () => undefined, + restore: () => undefined, + translate: () => undefined, + rotate: () => undefined, + fillRect: () => undefined, + } as unknown as CanvasRenderingContext2D); + vi.stubGlobal("requestAnimationFrame", () => 1); + }); + + afterEach(() => { + document.querySelectorAll("canvas").forEach((canvas) => canvas.remove()); + vi.unstubAllGlobals(); + vi.restoreAllMocks(); + }); + + it("celebrates the first reply exactly once", () => { + fireFirstReplyConfetti(); + expect(storage.getItem(FLAG_KEY)).toBe("1"); + expect(document.querySelectorAll("canvas")).toHaveLength(1); + + fireFirstReplyConfetti(); + expect(document.querySelectorAll("canvas")).toHaveLength(1); + }); + + it("skips a browser profile that already celebrated", () => { + storage.setItem(FLAG_KEY, "1"); + fireFirstReplyConfetti(); + expect(document.querySelectorAll("canvas")).toHaveLength(0); + }); + + it("skips reduced-motion users without burning the once-flag", () => { + stubMatchMedia(true); + fireFirstReplyConfetti(); + expect(document.querySelectorAll("canvas")).toHaveLength(0); + expect(storage.getItem(FLAG_KEY)).toBeNull(); + }); +}); diff --git a/ui/src/components/confetti.ts b/ui/src/components/confetti.ts new file mode 100644 index 000000000000..6d8790b340dd --- /dev/null +++ b/ui/src/components/confetti.ts @@ -0,0 +1,115 @@ +const FIRST_REPLY_CONFETTI_KEY = "openclaw.confetti.firstReply"; +const CONFETTI_COLORS = ["#ff4d4d", "#ff7079", "#00e5cc", "#f2a833"] as const; +const CONFETTI_DURATION_MS = 1_500; +const PARTICLE_COUNT = 64; + +type ConfettiStorage = Pick; + +type ConfettiParticle = { + color: (typeof CONFETTI_COLORS)[number]; + angle: number; + speed: number; + size: number; + spin: number; + rotation: number; +}; + +function shouldFireFirstReplyConfetti(storage: ConfettiStorage): boolean { + try { + if (storage.getItem(FIRST_REPLY_CONFETTI_KEY) !== null) { + return false; + } + storage.setItem(FIRST_REPLY_CONFETTI_KEY, "1"); + return true; + } catch { + return false; + } +} + +function createParticles(): ConfettiParticle[] { + return Array.from({ length: PARTICLE_COUNT }, (_, index) => ({ + // noUncheckedIndexedAccess cannot see the modulo bound; index 0 is typed. + color: CONFETTI_COLORS[index % CONFETTI_COLORS.length] ?? CONFETTI_COLORS[0], + angle: -Math.PI * (0.2 + Math.random() * 0.6), + speed: 260 + Math.random() * 360, + size: 4 + Math.random() * 5, + spin: (Math.random() - 0.5) * 12, + rotation: Math.random() * Math.PI, + })); +} + +export function fireFirstReplyConfetti(): void { + if ( + typeof document === "undefined" || + typeof window === "undefined" || + !document.body || + typeof window.requestAnimationFrame !== "function" || + window.matchMedia?.("(prefers-reduced-motion: reduce)").matches + ) { + return; + } + + let storage: Storage; + try { + storage = window.localStorage; + } catch { + return; + } + if (!shouldFireFirstReplyConfetti(storage)) { + return; + } + + const canvas = document.createElement("canvas"); + canvas.setAttribute("aria-hidden", "true"); + Object.assign(canvas.style, { + position: "fixed", + inset: "0", + width: "100vw", + height: "100vh", + pointerEvents: "none", + zIndex: "2147483647", + }); + const context = canvas.getContext("2d"); + if (!context) { + return; + } + document.body.append(canvas); + + const pixelRatio = Math.min(2, Math.max(1, window.devicePixelRatio || 1)); + const width = window.innerWidth; + const height = window.innerHeight; + canvas.width = Math.round(width * pixelRatio); + canvas.height = Math.round(height * pixelRatio); + context.setTransform(pixelRatio, 0, 0, pixelRatio, 0, 0); + + const particles = createParticles(); + const origin = { x: width / 2, y: height * 0.62 }; + const startedAt = performance.now(); + const draw = (timestamp: number) => { + const elapsedMs = timestamp - startedAt; + const elapsed = elapsedMs / 1_000; + const progress = elapsedMs / CONFETTI_DURATION_MS; + context.clearRect(0, 0, width, height); + context.globalAlpha = progress < 0.65 ? 1 : Math.max(0, (1 - progress) / 0.35); + + for (const particle of particles) { + const drag = Math.max(0, 1 - elapsed * 0.22); + const x = origin.x + Math.cos(particle.angle) * particle.speed * elapsed * drag; + const y = + origin.y + Math.sin(particle.angle) * particle.speed * elapsed + 420 * elapsed * elapsed; + context.save(); + context.translate(x, y); + context.rotate(particle.rotation + particle.spin * elapsed); + context.fillStyle = particle.color; + context.fillRect(-particle.size / 2, -particle.size / 4, particle.size, particle.size / 2); + context.restore(); + } + + if (progress < 1) { + window.requestAnimationFrame(draw); + } else { + canvas.remove(); + } + }; + window.requestAnimationFrame(draw); +} diff --git a/ui/src/components/mascot-animator.test.ts b/ui/src/components/mascot-animator.test.ts index d3578c1d13dc..cd95ba8e2067 100644 --- a/ui/src/components/mascot-animator.test.ts +++ b/ui/src/components/mascot-animator.test.ts @@ -127,6 +127,36 @@ describe("MascotAnimator", () => { expect(pose.mouthRound).toBeGreaterThan(0.8); expect(pose.leftEyeOpenness).toBeLessThan(0.1); }); + + it("overlays and clears the composer tease expression", () => { + const animator = new MascotAnimator(17); + animator.setMood("idle", 0); + animator.setTease(true, 0); + + expect(animator.poseAt(0)).toMatchObject({ mouthRound: 0.5, gaze: { x: 0, y: 0.6 } }); + + animator.setTease(false, 0.1); + const cleared = animator.poseAt(0.1); + expect(cleared.mouthRound).toBe(0); + expect(cleared.gaze).not.toEqual({ x: 0, y: 0.6 }); + }); + + it("plays one bounded catch beat and clears it after 0.8 seconds", () => { + const animator = new MascotAnimator(23); + animator.setMood("idle", 0); + animator.poseAt(0); + animator.playCatch(0.1); + + let peakHappyEyes = 0; + for (let frame = 0; frame <= 24; frame += 1) { + const pose = animator.poseAt(0.1 + frame / 30); + expectPoseInBounds(pose); + peakHappyEyes = Math.max(peakHappyEyes, pose.happyEyes); + } + expect(peakHappyEyes).toBeGreaterThan(0.8); + expect(animator.poseAt(0.91).happyEyes).toBe(0); + expect(animator.poseAt(1.2).happyEyes).toBe(0); + }); }); describe("staticMascotPose", () => { diff --git a/ui/src/components/mascot-animator.ts b/ui/src/components/mascot-animator.ts index 31d99b1f49ab..f98c1b1c164f 100644 --- a/ui/src/components/mascot-animator.ts +++ b/ui/src/components/mascot-animator.ts @@ -21,6 +21,7 @@ const NONZERO_SEED = 0x9e37_79b9_7f4a_7c15n; const XORSHIFT_MULTIPLIER = 2_685_821_657_736_338_717n; const TAU = Math.PI * 2; const BLINK_DURATION = 0.16; +const CATCH_DURATION = 0.8; function clamp(value: number, min = 0, max = 1): number { return Math.min(Math.max(value, min), max); @@ -115,6 +116,9 @@ export class MascotAnimator { private currentGaze = { x: 0, y: 0 }; private nextClawSnapAt = 0; private nextMoodBeatAt = 0; + private teaseActive = false; + private teaseChangedAt = 0; + private catchStartedAt: number | null = null; constructor(seed: bigint | number = BigInt(Date.now())) { this.rng = new SeededGenerator(seed); @@ -136,6 +140,15 @@ export class MascotAnimator { } } + setTease(active: boolean, time: number): void { + this.teaseActive = active; + this.teaseChangedAt = time; + } + + playCatch(time: number): void { + this.catchStartedAt = time; + } + poseAt(time: number): MascotPose { if (this.startTime === null) { this.begin(time); @@ -157,6 +170,23 @@ export class MascotAnimator { } } + if (this.teaseActive && time >= this.teaseChangedAt) { + pose.mouthRound = Math.max(pose.mouthRound, 0.5); + pose.gaze = { x: 0, y: 0.6 }; + } + if (this.catchStartedAt !== null) { + const progress = (time - this.catchStartedAt) / CATCH_DURATION; + if (progress >= 1) { + this.catchStartedAt = null; + } else if (progress >= 0) { + const flash = bell(progress); + this.applyGesture("clawSnap", pose, clamp(progress / 0.75)); + pose.happyEyes = Math.max(pose.happyEyes, 0.9 * flash); + pose.mouthCurve = Math.max(pose.mouthCurve, 0.7 * flash); + pose.blush = Math.max(pose.blush, 0.65 * flash); + } + } + return clampMascotPose(pose); } diff --git a/ui/src/components/openclaw-mascot.ts b/ui/src/components/openclaw-mascot.ts index f2696fc3111c..f2e5f56521ee 100644 --- a/ui/src/components/openclaw-mascot.ts +++ b/ui/src/components/openclaw-mascot.ts @@ -48,6 +48,7 @@ class OpenClawMascot extends LitElement { @property({ reflect: true }) mood: MascotMood = "idle"; @property({ type: Number }) size = DEFAULT_SIZE; + @property({ type: Boolean }) tease = false; private readonly animator = new MascotAnimator(); private animationFrame = 0; @@ -107,6 +108,7 @@ class OpenClawMascot extends LitElement { // Seed the animator's mood before the first pose so `begin()` schedules // for the real mood; `updated()` runs after this and would be too late. this.animator.setMood(this.resolvedMood, currentSeconds()); + this.animator.setTease(this.tease, currentSeconds()); this.drawCurrentFrame(currentSeconds()); this.syncPlayback(); } @@ -118,12 +120,25 @@ class OpenClawMascot extends LitElement { if (changed.has("mood")) { this.animator.setMood(this.resolvedMood, currentSeconds()); } - if (changed.has("size") || changed.has("mood")) { + if (changed.has("tease")) { + this.animator.setTease(this.tease, currentSeconds()); + } + if (changed.has("size") || changed.has("mood") || changed.has("tease")) { this.drawCurrentFrame(currentSeconds()); this.syncPlayback(); } } + catchOnce(): void { + if (!this.isConnected || this.reducedMotion) { + return; + } + const time = currentSeconds(); + this.animator.playCatch(time); + this.drawCurrentFrame(time); + this.syncPlayback(); + } + override render() { return html``; } diff --git a/ui/src/pages/channels/view.ts b/ui/src/pages/channels/view.ts index b79f8eab562f..01d4a35c90be 100644 --- a/ui/src/pages/channels/view.ts +++ b/ui/src/pages/channels/view.ts @@ -16,6 +16,7 @@ import type { WhatsAppStatus, } from "../../api/types.ts"; import { icons } from "../../components/icons.ts"; +import "../../components/openclaw-mascot.ts"; import { renderSettingsEmpty, renderSettingsPage, @@ -81,7 +82,13 @@ export function renderChannels(props: ChannelsProps) { `, }, connected.length === 0 - ? renderSettingsEmpty(t("channels.hub.noneConnected")) + ? html` +
+ + + ${renderSettingsEmpty(t("channels.hub.noneConnected"))} +
+ ` : connected.map((key) => renderConnectedRow(key, props)), )} ${renderSettingsSection( diff --git a/ui/src/pages/chat/chat-state.ts b/ui/src/pages/chat/chat-state.ts index 6bcd394896f4..7b926e9c6af9 100644 --- a/ui/src/pages/chat/chat-state.ts +++ b/ui/src/pages/chat/chat-state.ts @@ -18,8 +18,10 @@ import { patchSettings, type UiSettings, } from "../../app/settings.ts"; +import { fireFirstReplyConfetti } from "../../components/confetti.ts"; import { isRenderableControlUiAvatarUrl } from "../../lib/avatar.ts"; import type { ChatAttachment, ChatQueueItem } from "../../lib/chat/chat-types.ts"; +import { extractText } from "../../lib/chat/message-extract.ts"; import { retirePendingChatSideQuestion, type ChatSideResult } from "../../lib/chat/side-result.ts"; import type { EmbedSandboxMode } from "../../lib/chat/tool-display.ts"; import { isGatewayMethodAdvertised } from "../../lib/gateway-methods.ts"; @@ -55,7 +57,9 @@ import { } from "./chat-gateway.ts"; import { chatScopedEventSessionMatches, + isHiddenAssistantStreamText, loadChatHistory, + shouldHideAssistantChatMessage, type ChatMetadataResult, type ChatState, } from "./chat-history.ts"; @@ -1396,9 +1400,41 @@ export function createPageState( return state; } +function hasVisibleFinalAssistantReply( + state: ChatPageHost, + payload: ChatEventPayload | undefined, +): boolean { + if (payload?.state !== "final") { + return false; + } + const ownsReply = + chatScopedEventSessionMatches(state, payload.sessionKey, payload.agentId) || + (typeof payload.runId === "string" && payload.runId === state.chatRunId); + if (!ownsReply) { + return false; + } + const finalText = extractText(payload.message); + if ( + typeof finalText === "string" && + finalText.trim().length > 0 && + !isHiddenAssistantStreamText(finalText) && + !shouldHideAssistantChatMessage(payload.message) + ) { + return true; + } + return [ + state.chatStream, + ...(state.chatStreamSegments ?? []).map((segment) => segment.text), + ].some( + (text) => + typeof text === "string" && text.trim().length > 0 && !isHiddenAssistantStreamText(text), + ); +} + export function handlePageGatewayEvent(state: ChatPageHost, event: GatewayEventFrame) { if (event.event === "chat") { const payload = event.payload as ChatEventPayload | undefined; + const shouldCelebrateFirstReply = hasVisibleFinalAssistantReply(state, payload); const terminal = payload?.state === "final" || payload?.state === "aborted" || payload?.state === "error"; const delivered = terminal ? rememberDeliveredQueuedUserTurn(state, payload?.runId) : null; @@ -1407,7 +1443,10 @@ export function handlePageGatewayEvent(state: ChatPageHost, event: GatewayEventF // Materialize it before the terminal assistant to preserve transcript order. preserveDeliveredQueuedUserTurn(state, delivered); } - handleChatGatewayEvent(state as unknown as ChatState, payload); + const result = handleChatGatewayEvent(state as unknown as ChatState, payload); + if (shouldCelebrateFirstReply && result === "final") { + fireFirstReplyConfetti(); + } replayPendingSessionMessageReload(state, payload); if (terminal) { removeDeliveredQueuedChatSendForRun(state, payload?.runId); diff --git a/ui/src/pages/chat/chat-view.test.ts b/ui/src/pages/chat/chat-view.test.ts index 1ab9e4bf8c87..912485019e0d 100644 --- a/ui/src/pages/chat/chat-view.test.ts +++ b/ui/src/pages/chat/chat-view.test.ts @@ -3937,6 +3937,24 @@ describe("chat welcome", () => { expect(container.querySelector(".agent-chat__badge")).toBeNull(); }); + it("teases and catches file drags with the welcome mascot", () => { + const container = renderWelcome({ assistantAvatar: null, assistantAvatarUrl: null }); + const welcome = requireElement(container, ".agent-chat__welcome", "welcome screen"); + const mascot = requireElement( + container, + ".agent-chat__welcome-clawd openclaw-mascot", + "welcome mascot", + ) as HTMLElement & { tease: boolean; catchOnce: () => void }; + const catchOnce = vi.spyOn(mascot, "catchOnce"); + + welcome.dispatchEvent(createDragEvent("dragenter")); + expect(mascot.tease).toBe(true); + + welcome.dispatchEvent(createDragEvent("drop")); + expect(mascot.tease).toBe(false); + expect(catchOnce).toHaveBeenCalledOnce(); + }); + it("renders welcome text from the active locale", async () => { await i18n.setLocale("zh-CN"); const container = renderWelcome({ assistantAvatar: "VC", assistantAvatarUrl: null }); diff --git a/ui/src/pages/chat/components/chat-welcome.ts b/ui/src/pages/chat/components/chat-welcome.ts index 5940c35d6a67..54eb37effab3 100644 --- a/ui/src/pages/chat/components/chat-welcome.ts +++ b/ui/src/pages/chat/components/chat-welcome.ts @@ -34,6 +34,8 @@ type ChatWelcomeProps = { onOpenSession?: (sessionKey: string) => void; }; +type WelcomeMascot = HTMLElement & { tease: boolean; catchOnce: () => void }; + const WELCOME_SUGGESTION_KEYS = [ "chat.welcome.suggestions.whatCanYouDo", "chat.welcome.suggestions.summarizeRecentSessions", @@ -168,9 +170,47 @@ function renderWelcomeHero( /** The start-screen welcome block, shared by the empty chat and the new-session draft. */ export function renderWelcomeState(props: ChatWelcomeProps) { const recentSessions = selectWelcomeRecentSessions(props); + let fileDragDepth = 0; + const mascotFor = (event: DragEvent): WelcomeMascot | null => { + const target = event.currentTarget; + return target instanceof HTMLElement + ? target.querySelector(".agent-chat__welcome-clawd openclaw-mascot") + : null; + }; return html` -
+
{ + if (!Array.from(event.dataTransfer?.types ?? []).includes("Files")) { + return; + } + fileDragDepth += 1; + const mascot = mascotFor(event); + if (mascot) { + mascot.tease = true; + } + }} + @dragleave=${(event: DragEvent) => { + fileDragDepth = Math.max(0, fileDragDepth - 1); + const mascot = mascotFor(event); + if (mascot && fileDragDepth === 0) { + mascot.tease = false; + } + }} + @drop=${(event: DragEvent) => { + if (!Array.from(event.dataTransfer?.types ?? []).includes("Files")) { + return; + } + fileDragDepth = 0; + const mascot = mascotFor(event); + if (mascot) { + mascot.tease = false; + mascot.catchOnce(); + } + }} + > ${renderWelcomeHero({ assistantName: props.assistantName, assistantAvatar: props.assistantAvatar, diff --git a/ui/src/pages/plugins/view.ts b/ui/src/pages/plugins/view.ts index 8b665e863b2c..b9c38360441c 100644 --- a/ui/src/pages/plugins/view.ts +++ b/ui/src/pages/plugins/view.ts @@ -8,6 +8,7 @@ import { live } from "lit/directives/live.js"; import { repeat } from "lit/directives/repeat.js"; import { icons } from "../../components/icons.ts"; import "../../components/modal-dialog.ts"; +import "../../components/openclaw-mascot.ts"; import { renderSettingsEmpty, renderSettingsPage, @@ -722,16 +723,14 @@ function renderMcpForm(props: PluginsViewProps) { function renderInstalled(props: PluginsViewProps) { const plugins = installedPlugins(props.result?.plugins ?? [], props.query, props.installedFilter); const groups = groupInstalledByCategory(plugins); + const filtered = Boolean(props.query || props.installedFilter !== "all"); return html` ${renderInstalledFilter(props)} ${groups.length === 0 ? renderEmpty( - props.query || props.installedFilter !== "all" - ? t("pluginsPage.noInstalledMatchTitle") - : t("pluginsPage.noInstalledTitle"), - props.query || props.installedFilter !== "all" - ? t("pluginsPage.noMatchBody") - : t("pluginsPage.noInstalledBody"), + filtered ? t("pluginsPage.noInstalledMatchTitle") : t("pluginsPage.noInstalledTitle"), + filtered ? t("pluginsPage.noMatchBody") : t("pluginsPage.noInstalledBody"), + filtered ? "curious" : "sleepy", ) : groups.map((group) => renderSettingsSection( @@ -989,7 +988,11 @@ function renderDiscover(props: PluginsViewProps) { if (!featuredRows.length && !officialRows.length && !shelves.connectors.length) { return html` ${clawHub === nothing - ? renderEmpty(t("pluginsPage.noDiscoverMatchTitle"), t("pluginsPage.noMatchBody")) + ? renderEmpty( + t("pluginsPage.noDiscoverMatchTitle"), + t("pluginsPage.noMatchBody"), + "curious", + ) : nothing} ${clawHub} `; @@ -1163,10 +1166,17 @@ function renderDetailCover(slug: string, name: string): TemplateResult { /* ---------------------------------- page shell ---------------------------------- */ -function renderEmpty(title: string, body: string) { +function renderEmpty(title: string, body: string, mood?: "sleepy" | "curious") { return html`
- + + ${mood + ? html`` + : html``}

${title}

${body}

diff --git a/ui/src/styles/channels.css b/ui/src/styles/channels.css index 2843f7fbc0b0..1865a893f683 100644 --- a/ui/src/styles/channels.css +++ b/ui/src/styles/channels.css @@ -3,6 +3,16 @@ shared settings design language. */ /* Row with a leading 44px art tile (mirrors the plugins hub row anatomy). */ +.channels-empty { + display: grid; + place-items: center; + padding-top: var(--space-4); +} + +.channels-empty .settings-empty { + padding-top: var(--space-1); +} + .channels-item { width: 100%; text-align: left; diff --git a/ui/src/styles/plugins.css b/ui/src/styles/plugins.css index efc34923722b..6fc919d1ff9e 100644 --- a/ui/src/styles/plugins.css +++ b/ui/src/styles/plugins.css @@ -427,10 +427,14 @@ h3.plugins-subheader { text-align: center; } +.plugins-empty__icon, +.plugins-empty__mascot { + margin-bottom: var(--space-3); +} + .plugins-empty__icon { width: 32px; height: 32px; - margin-bottom: var(--space-3); color: var(--border-hover); }