+ {shouldShowContactPanel ? (
+
+

+

+
+ ) : null}
+
+
,
+ document.body,
+ );
+}
diff --git a/src/components/common/floatingFeedbackEntryModel.ts b/src/components/common/floatingFeedbackEntryModel.ts
new file mode 100644
index 000000000..a45100f8d
--- /dev/null
+++ b/src/components/common/floatingFeedbackEntryModel.ts
@@ -0,0 +1,81 @@
+export const FLOATING_FEEDBACK_FORM_URL =
+ 'https://kcnz41bksl1c.feishu.cn/share/base/form/shrcn1p9WpuZMDxrI5ysLLJhw7k';
+
+export const FLOATING_FEEDBACK_DESKTOP_QUERY = '(min-width: 1024px)';
+export const FLOATING_FEEDBACK_SIZE = 57.6;
+export const FLOATING_FEEDBACK_MARGIN = 20;
+export const FLOATING_FEEDBACK_INITIAL_LEFT = 24;
+export const FLOATING_FEEDBACK_INITIAL_BOTTOM = 96;
+export const FLOATING_FEEDBACK_DRAG_CLICK_THRESHOLD = 4;
+export const FLOATING_FEEDBACK_DRAG_CLICK_SUPPRESS_MS = 250;
+export const FLOATING_FEEDBACK_CONTACT_PANEL_WIDTH = 348;
+
+export type FloatingFeedbackPosition = {
+ left: number;
+ top: number;
+};
+
+function roundFloatingFeedbackCoordinate(value: number) {
+ return Math.round(value * 10) / 10;
+}
+
+export function clampFloatingFeedbackPosition(
+ position: FloatingFeedbackPosition,
+ viewport: { width: number; height: number },
+) {
+ const minLeft = FLOATING_FEEDBACK_MARGIN;
+ const minTop = FLOATING_FEEDBACK_MARGIN;
+ const maxLeft = Math.max(
+ minLeft,
+ viewport.width - FLOATING_FEEDBACK_SIZE - FLOATING_FEEDBACK_MARGIN,
+ );
+ const maxTop = Math.max(
+ minTop,
+ viewport.height - FLOATING_FEEDBACK_SIZE - FLOATING_FEEDBACK_MARGIN,
+ );
+
+ return {
+ left: roundFloatingFeedbackCoordinate(
+ Math.min(maxLeft, Math.max(minLeft, position.left)),
+ ),
+ top: roundFloatingFeedbackCoordinate(
+ Math.min(maxTop, Math.max(minTop, position.top)),
+ ),
+ };
+}
+
+export function resolveInitialFloatingFeedbackPosition(viewport: {
+ width: number;
+ height: number;
+}) {
+ return clampFloatingFeedbackPosition(
+ {
+ left: FLOATING_FEEDBACK_INITIAL_LEFT,
+ top:
+ viewport.height -
+ FLOATING_FEEDBACK_SIZE -
+ FLOATING_FEEDBACK_INITIAL_BOTTOM,
+ },
+ viewport,
+ );
+}
+
+export function resolveFloatingFeedbackContactPanelOffset(
+ position: FloatingFeedbackPosition,
+ viewport: { width: number },
+) {
+ const entryCenter = position.left + FLOATING_FEEDBACK_SIZE / 2;
+ const minCenter = FLOATING_FEEDBACK_MARGIN + FLOATING_FEEDBACK_CONTACT_PANEL_WIDTH / 2;
+ const maxCenter =
+ viewport.width -
+ FLOATING_FEEDBACK_MARGIN -
+ FLOATING_FEEDBACK_CONTACT_PANEL_WIDTH / 2;
+
+ if (maxCenter < minCenter) {
+ return 0;
+ }
+
+ return roundFloatingFeedbackCoordinate(
+ Math.min(maxCenter, Math.max(minCenter, entryCenter)) - entryCenter,
+ );
+}
diff --git a/src/index.css b/src/index.css
index 283343c15..d1a7c607a 100644
--- a/src/index.css
+++ b/src/index.css
@@ -1562,6 +1562,75 @@ html[data-mobile-keyboard-open='true'] #root {
color: var(--platform-brand-logo-subtitle);
}
+.floating-feedback-entry-shell {
+ position: fixed;
+ z-index: 2147483647;
+ width: 57.6px;
+ height: 57.6px;
+}
+
+.floating-feedback-entry-contact-panel {
+ position: absolute;
+ left: 50%;
+ bottom: calc(100% + 12px);
+ display: flex;
+ box-sizing: border-box;
+ width: 348px;
+ gap: 12px;
+ align-items: center;
+ justify-content: center;
+ padding: 10px;
+ border: 1px solid rgba(214, 154, 111, 0.24);
+ border-radius: 12px;
+ background: rgba(255, 252, 248, 0.97);
+ box-shadow: 0 18px 36px rgba(74, 34, 15, 0.18);
+ pointer-events: none;
+ transform: translateX(calc(-50% + var(--floating-feedback-contact-offset, 0px)));
+}
+
+.floating-feedback-entry-contact-panel img {
+ display: block;
+ width: 156px;
+ height: 156px;
+ border-radius: 8px;
+ object-fit: contain;
+ user-select: none;
+}
+
+.floating-feedback-entry {
+ position: relative;
+ display: inline-flex;
+ width: 57.6px;
+ height: 57.6px;
+ align-items: center;
+ justify-content: center;
+ border: 0;
+ border-radius: 999px;
+ background: transparent;
+ padding: 0;
+ cursor: grab;
+ touch-action: none;
+ user-select: none;
+ filter: drop-shadow(0 0.7rem 1.05rem rgba(98, 48, 24, 0.24));
+}
+
+.floating-feedback-entry:active {
+ cursor: grabbing;
+}
+
+.floating-feedback-entry:focus-visible {
+ outline: 3px solid rgba(204, 117, 76, 0.72);
+ outline-offset: 4px;
+}
+
+.floating-feedback-entry img {
+ display: block;
+ width: 100%;
+ height: 100%;
+ pointer-events: none;
+ user-select: none;
+}
+
.platform-main-shell {
position: relative;
width: 100%;
diff --git a/src/main.tsx b/src/main.tsx
index 17fd6ac8e..c730f9992 100644
--- a/src/main.tsx
+++ b/src/main.tsx
@@ -5,6 +5,7 @@ import './index.css';
import {StrictMode, Suspense} from 'react';
import {createRoot} from 'react-dom/client';
+import {FloatingFeedbackEntry} from './components/common/FloatingFeedbackEntry';
import {stabilizeMobileViewportKeyboardFocus} from './mobileViewportKeyboardFocus';
import {lockMobileViewportZoom} from './mobileViewportZoomLock';
import {resolveAppRoute} from './routing/appRoutes';
@@ -54,5 +55,6 @@ root.render(