fix: restore circular add button
ci / gitleaks (push) Successful in 7s
ci / docker (push) Successful in 7m29s

This commit is contained in:
2026-09-15 17:40:01 +08:00
parent e857506bf6
commit 039d3f110d
3 changed files with 37 additions and 106 deletions
+10 -54
View File
@@ -1,24 +1,21 @@
<script setup lang="ts">
import { computed, onMounted, onUnmounted, ref } from 'vue'
import { Plus } from 'lucide-vue-next'
import { clampFabPosition, isFabDrag, snapFabPosition } from '../lib/mvp-utils'
const props = withDefaults(defineProps<{ label?: string; show?: boolean }>(), { label: '添加', show: true })
const emit = defineEmits<{ activate: [origin: { x: number; y: number }] }>()
const dragging = ref(false)
const snapping = ref(false)
const edge = ref<'left' | 'right'>('right')
const position = ref<{ x: number; y: number } | null>(null)
const pupilOffset = ref({ x: 0, y: 0 })
const pointer = ref<{ id: number; startX: number; startY: number; originX: number; originY: number; lastX: number; lastY: number } | null>(null)
const pointer = ref<{ id: number; startX: number; startY: number; originX: number; originY: number } | null>(null)
let suppressClick = false
let suppressClickTimer: number | undefined
let snappingTimer: number | undefined
const buttonStyle = computed(() => ({
...(position.value ? { left: `${position.value.x}px`, top: `${position.value.y}px`, right: 'auto', bottom: 'auto' } : {}),
'--pupil-x': `${pupilOffset.value.x}px`,
'--pupil-y': `${pupilOffset.value.y}px`,
}))
const buttonStyle = computed(() => position.value
? { left: `${position.value.x}px`, top: `${position.value.y}px`, right: 'auto', bottom: 'auto' }
: {})
function prefersReducedMotion() {
return window.matchMedia('(prefers-reduced-motion: reduce)').matches
@@ -31,37 +28,25 @@ function clearSnappingTimer() {
}
function startSnappingPulse() {
clearSnappingTimer()
if (prefersReducedMotion()) {
pupilOffset.value = { x: 0, y: 0 }
return
}
snappingTimer = window.setTimeout(() => {
pupilOffset.value = { x: 0, y: 0 }
if (prefersReducedMotion()) return
snapping.value = true
snappingTimer = window.setTimeout(() => {
snapping.value = false
snappingTimer = undefined
}, 460)
}, 200)
}
function updateEdge() {
if (!position.value) return
edge.value = position.value.x + 28 <= window.innerWidth / 2 ? 'left' : 'right'
}, 220)
}
function snapFabToCurrentViewport() {
if (!position.value || dragging.value) return
position.value = snapFabPosition(position.value.x, position.value.y, window.innerWidth, window.innerHeight, 56, 14, 84)
updateEdge()
}
function startDrag(event: PointerEvent) {
if (!event.isPrimary || (event.pointerType === 'mouse' && event.button !== 0)) return
clearSnappingTimer()
snapping.value = false
pupilOffset.value = { x: 0, y: 0 }
const target = event.currentTarget as HTMLElement
const rect = target.getBoundingClientRect()
position.value = clampFabPosition(rect.left, rect.top, window.innerWidth, window.innerHeight, 56, 14, 84)
pointer.value = { id: event.pointerId, startX: event.clientX, startY: event.clientY, originX: rect.left, originY: rect.top, lastX: event.clientX, lastY: event.clientY }
pointer.value = { id: event.pointerId, startX: event.clientX, startY: event.clientY, originX: rect.left, originY: rect.top }
try { target.setPointerCapture?.(event.pointerId) } catch { /* pointer capture unavailable in synthetic events */ }
}
function moveDrag(event: PointerEvent) {
@@ -69,14 +54,8 @@ function moveDrag(event: PointerEvent) {
if (!start || start.id !== event.pointerId) return
const deltaX = event.clientX - start.startX
const deltaY = event.clientY - start.startY
const vectorX = event.clientX - start.lastX
const vectorY = event.clientY - start.lastY
start.lastX = event.clientX
start.lastY = event.clientY
if (!dragging.value && !isFabDrag(deltaX, deltaY)) return
dragging.value = true
const length = Math.hypot(vectorX, vectorY)
if (!prefersReducedMotion() && length > 1) pupilOffset.value = { x: vectorX / length * 1.5, y: vectorY / length * 1.1 }
position.value = clampFabPosition(start.originX + deltaX, start.originY + deltaY, window.innerWidth, window.innerHeight, 56, 14, 84)
}
function finishDrag(event: PointerEvent) {
@@ -87,7 +66,6 @@ function finishDrag(event: PointerEvent) {
if (dragged) {
const current = position.value ?? clampFabPosition(start.originX, start.originY, window.innerWidth, window.innerHeight, 56, 14, 84)
position.value = snapFabPosition(current.x, current.y, window.innerWidth, window.innerHeight, 56, 14, 84)
updateEdge()
startSnappingPulse()
suppressClick = true
if (suppressClickTimer) window.clearTimeout(suppressClickTimer)
@@ -95,8 +73,6 @@ function finishDrag(event: PointerEvent) {
suppressClick = false
suppressClickTimer = undefined
}, 180)
} else {
pupilOffset.value = { x: 0, y: 0 }
}
dragging.value = false
}
@@ -115,27 +91,7 @@ onUnmounted(() => {
</script>
<template>
<button v-if="props.show" class="unified-fab" :class="{ dragging, snapping, 'edge-left': edge === 'left', 'edge-right': edge === 'right' }" :style="buttonStyle" :aria-label="props.label" @pointerdown="startDrag" @pointermove="moveDrag" @pointerup="finishDrag" @pointercancel="finishDrag" @click="activate">
<svg class="fab-cat" viewBox="0 0 56 56" aria-hidden="true">
<g class="fab-cat__tail"><path d="M40.5 36.8 C46.2 38.4 47 43.2 43 46"/></g>
<g class="fab-cat__character">
<g class="fab-cat__ears">
<path class="fab-cat__ear fab-cat__ear--left" d="M15.8 19.2 14.2 10.1Q14 9 15.1 9.6L23 15.4Z"/>
<path class="fab-cat__inner-ear" d="M17 16.1 16 12.5l4.2 3.1Z"/>
<path class="fab-cat__ear fab-cat__ear--right" d="M33.7 15.2 42.1 9.4Q43.2 8.7 43 10.1l-1.6 9.2Z"/>
<path class="fab-cat__inner-ear" d="m37 15.5 4-3.1-.7 4.1Z"/>
</g>
<g class="fab-cat__head">
<path class="fab-cat__face-shape" d="M13 26.2C13 18.2 19.1 14.2 28.1 14.2c9.1 0 15.3 4.1 15.3 12.1 0 9.5-6.2 15-15.2 15S13 35.8 13 26.2Z"/>
<g class="fab-cat__eyes">
<g class="fab-cat__eye fab-cat__eye--left"><ellipse cx="21.7" cy="28.1" rx="3.15" ry="2.25"/></g>
<g class="fab-cat__eye fab-cat__eye--right"><ellipse cx="34.5" cy="28.1" rx="3.15" ry="2.25"/></g>
</g>
<g class="fab-cat__pupils"><circle cx="21.7" cy="28.1" r="1.25"/><circle cx="34.5" cy="28.1" r="1.25"/></g>
<g class="fab-cat__muzzle"><path class="fab-cat__nose" d="M26.7 32.5Q28.1 33.6 29.5 32.5 28.1 31.5 26.7 32.5Z"/><path class="fab-cat__mouth" d="M28.1 33.3v.9m0 0q-1.3 1-2.4.5m2.4-.5q1.3 1 2.4.5"/></g>
<path class="fab-cat__plus" d="M24.8 20.4 H31.4 M28.1 17.1 V23.7"/>
</g>
</g>
</svg>
<button v-if="props.show" class="unified-fab" :class="{ dragging, snapping }" :style="buttonStyle" :aria-label="props.label" @pointerdown="startDrag" @pointermove="moveDrag" @pointerup="finishDrag" @pointercancel="finishDrag" @click="activate">
<Plus />
</button>
</template>
File diff suppressed because one or more lines are too long
+22 -47
View File
@@ -1098,19 +1098,14 @@ describe('unified floating add interaction', () => {
expect(css).toContain('@media(max-width:930px){.unified-fab{bottom:calc(82px + env(safe-area-inset-bottom))}')
})
it('renders the approved open-eyed orange cat and mirrors only its tail tip at an edge', () => {
expect(floatingAdd).toContain('class="fab-cat"')
expect(floatingAdd).toContain('class="fab-cat__eye fab-cat__eye--left"')
expect(floatingAdd).toContain('class="fab-cat__eye fab-cat__eye--right"')
expect(floatingAdd).toContain("const edge = ref<'left' | 'right'>('right')")
expect(floatingAdd).toContain("'edge-left': edge === 'left'")
expect(floatingAdd).not.toContain('sleeping: !dragging')
expect(css).toContain('.unified-fab .fab-cat{')
expect(css).toContain('.fab-cat__eye{fill:#fff9f0;stroke:#98482c;stroke-width:1.15;')
expect(css).toContain('.unified-fab.edge-left .fab-cat__tail{transform:translateX(56px) scaleX(-1)}')
expect(css).not.toContain('.unified-fab.edge-left .fab-cat{transform:scaleX(-1)}')
expect(css).not.toContain('@keyframes fab-cat-breathe')
expect(css).toContain('@media(prefers-reduced-motion:reduce){.unified-fab,.unified-fab *')
it('renders the original orange circular plus button', () => {
expect(floatingAdd).toContain("import { Plus } from 'lucide-vue-next'")
expect(floatingAdd).toContain('<Plus />')
expect(floatingAdd).not.toContain('fab-cat')
expect(floatingAdd).not.toContain('pupilOffset')
expect(css).toContain('.unified-fab{display:grid;place-items:center;position:fixed;z-index:60;right:20px;bottom:22px;width:56px;height:56px;padding:0;border:0;border-radius:50%;background:var(--accent);color:#fff;')
expect(css).toContain('.unified-fab>svg{width:25px;height:25px}')
expect(css).not.toContain('.fab-cat__')
})
it('keeps the countdown close button but removes its orange circular background', () => {
@@ -1369,48 +1364,28 @@ describe('sidebar layout', () => {
})
})
describe('orange dumpling cat floating add button', () => {
it('renders only the approved asymmetric cat head, tail tip, open eyes, and white forehead plus', () => {
expect(floatingAdd).toContain('class="fab-cat__tail"')
expect(floatingAdd).toContain('class="fab-cat__ear fab-cat__ear--left"')
expect(floatingAdd).toContain('class="fab-cat__ear fab-cat__ear--right"')
expect(floatingAdd).toContain('class="fab-cat__face-shape"')
expect(floatingAdd).toContain('class="fab-cat__pupils"')
expect(floatingAdd).toContain('class="fab-cat__plus"')
expect(floatingAdd).toContain('d="M24.8 20.4 H31.4 M28.1 17.1 V23.7"')
expect(floatingAdd).not.toMatch(/fab-cat-(body|back|sleep)|<text/)
})
it('tracks the latest drag vector for bounded pupil movement and keeps existing drag and activation contracts', () => {
expect(floatingAdd).toContain('const pupilOffset = ref({ x: 0, y: 0 })')
describe('original circular floating add button', () => {
it('keeps only the plus icon while preserving drag and activation contracts', () => {
expect(floatingAdd).toContain('<Plus />')
expect(floatingAdd).not.toContain('fab-cat')
expect(floatingAdd).not.toContain('pupilOffset')
expect(floatingAdd).toContain('let suppressClickTimer: number | undefined')
expect(floatingAdd).toContain("'--pupil-x': `${pupilOffset.value.x}px`")
expect(floatingAdd).toContain("'--pupil-y': `${pupilOffset.value.y}px`")
expect(floatingAdd).toContain('const length = Math.hypot(vectorX, vectorY)')
expect(floatingAdd).toContain('x: vectorX / length * 1.5')
expect(floatingAdd).toContain('y: vectorY / length * 1.1')
expect(floatingAdd).toContain('isFabDrag(deltaX, deltaY)')
expect(floatingAdd).toContain('if (suppressClickTimer) window.clearTimeout(suppressClickTimer)')
expect(floatingAdd).toContain("emit('activate', { x: rect.left + rect.width / 2, y: rect.top + rect.height / 2 })")
})
it('uses the approved colors and restrained state motion without mirroring the whole cat', () => {
expect(css).toContain('.unified-fab{display:grid;place-items:center;position:fixed;z-index:60;right:20px;bottom:22px;width:56px;height:56px;padding:0;border:1px solid #e9b88f;border-radius:50%;background:#fff3df;')
expect(css).toContain('.fab-cat__face-shape,.fab-cat__ear{fill:#f28c52;stroke:#98482c;stroke-width:1.5;')
expect(css).toContain('.fab-cat__plus{fill:none;stroke:#fff;stroke-width:2.4;')
expect(css).toContain('animation:fab-cat-blink 8s 2.4s infinite')
expect(css).toContain('@keyframes fab-cat-blink{0%,5%,100%{transform:scaleY(1)}1.875%,2.875%{transform:scaleY(.12)}}')
expect(css).toContain('@media(hover:hover) and (pointer:fine)')
expect(css).toContain('.unified-fab.dragging{box-shadow:inset 0 1px 0 rgba(255,255,255,.78),0 9px 20px rgba(112,65,35,.24)}')
expect(css).toContain('.unified-fab.snapping .fab-cat__tail{animation:fab-cat-tail-wag .46s cubic-bezier(.25,.8,.35,1) 1}')
expect(css).toContain('.unified-fab:focus-visible{outline:3px solid #a84628;outline-offset:3px}')
expect(css).not.toContain('.unified-fab.edge-left .fab-cat{transform:scaleX(-1)}')
expect(css).not.toContain('fab-cat-breathe')
it('uses the original orange circle visuals and restrained motion', () => {
expect(css).toContain('.unified-fab{display:grid;place-items:center;position:fixed;z-index:60;right:20px;bottom:22px;width:56px;height:56px;padding:0;border:0;border-radius:50%;background:var(--accent);color:#fff;')
expect(css).toContain('.unified-fab.dragging{transform:scale(1.06);')
expect(css).toContain('.unified-fab.snapping{box-shadow:0 10px 24px rgba(241,90,41,.32)}')
expect(css).toContain('.unified-fab:focus-visible{outline:3px solid var(--focus-ring);outline-offset:3px}')
expect(css).not.toContain('.fab-cat__')
})
it('disables every cat motion for reduced-motion users while leaving pointer behavior intact', () => {
expect(css).toContain('@media(prefers-reduced-motion:reduce){.unified-fab,.unified-fab *,.unified-fab:before,.unified-fab:after{animation:none!important;transition:none!important}')
it('keeps reduced-motion handling without cat-only behavior', () => {
expect(floatingAdd).toContain("window.matchMedia('(prefers-reduced-motion: reduce)').matches")
expect(floatingAdd).toContain('pupilOffset.value = { x: 0, y: 0 }')
expect(floatingAdd).toContain('if (prefersReducedMotion()) return')
expect(floatingAdd).not.toContain('pupilOffset')
})
})