feat: add paper-style mobile navigation
ci / gitleaks (push) Successful in 8s
ci / docker (push) Successful in 3m35s

This commit is contained in:
2026-09-18 08:34:36 +08:00
parent a4c1ac5978
commit 7bfc5d2a44
6 changed files with 155 additions and 12 deletions
+13 -6
View File
@@ -1,7 +1,7 @@
<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'
import { clampFabPosition, fabBottomReserved, 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 }] }>()
@@ -35,9 +35,16 @@ function startSnappingPulse() {
snappingTimer = undefined
}, 220)
}
function safeAreaBottom() {
const value = getComputedStyle(document.documentElement).getPropertyValue('--safe-area-bottom')
return Number.parseFloat(value) || 0
}
function currentBottomReserved() {
return window.innerWidth <= 930 ? fabBottomReserved(safeAreaBottom()) : 84
}
function snapFabToCurrentViewport() {
if (!position.value || dragging.value) return
position.value = snapFabPosition(position.value.x, position.value.y, window.innerWidth, window.innerHeight, 56, 14, 84)
position.value = snapFabPosition(position.value.x, position.value.y, window.innerWidth, window.innerHeight, 56, 14, currentBottomReserved())
}
function startDrag(event: PointerEvent) {
if (!event.isPrimary || (event.pointerType === 'mouse' && event.button !== 0)) return
@@ -45,7 +52,7 @@ function startDrag(event: PointerEvent) {
snapping.value = false
const target = event.currentTarget as HTMLElement
const rect = target.getBoundingClientRect()
position.value = clampFabPosition(rect.left, rect.top, window.innerWidth, window.innerHeight, 56, 14, 84)
position.value = clampFabPosition(rect.left, rect.top, window.innerWidth, window.innerHeight, 56, 14, currentBottomReserved())
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 */ }
}
@@ -56,7 +63,7 @@ function moveDrag(event: PointerEvent) {
const deltaY = event.clientY - start.startY
if (!dragging.value && !isFabDrag(deltaX, deltaY)) return
dragging.value = true
position.value = clampFabPosition(start.originX + deltaX, start.originY + deltaY, window.innerWidth, window.innerHeight, 56, 14, 84)
position.value = clampFabPosition(start.originX + deltaX, start.originY + deltaY, window.innerWidth, window.innerHeight, 56, 14, currentBottomReserved())
}
function finishDrag(event: PointerEvent) {
const start = pointer.value
@@ -64,8 +71,8 @@ function finishDrag(event: PointerEvent) {
const dragged = dragging.value || isFabDrag(event.clientX - start.startX, event.clientY - start.startY)
pointer.value = null
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)
const current = position.value ?? clampFabPosition(start.originX, start.originY, window.innerWidth, window.innerHeight, 56, 14, currentBottomReserved())
position.value = snapFabPosition(current.x, current.y, window.innerWidth, window.innerHeight, 56, 14, currentBottomReserved())
startSnappingPulse()
suppressClick = true
if (suppressClickTimer) window.clearTimeout(suppressClickTimer)
+7 -1
View File
@@ -1,5 +1,5 @@
import { describe, expect, it } from 'vitest'
import { archivePanelFlags, calendarModeLabel, changedHabitFields, clampFabPosition, countdownDayText, countdownKindLabel, dateKey, dayBefore, defaultView, formatArchivedAt, formatAuditAction, formatAuditEntity, formatHabitApiError, formatHabitHistoryNumber, formatLocalShortDateTime, formatUserAgent, habitActionState, habitButtonNotice, habitButtonValue, habitHistoryWindow, habitWeek, invalidateHabitGridCache, isFabDrag, isHabitComplete, isHabitScheduledToday, isTaskView, mergeHabitHistory, nextHabitSwipeValue, nextTotalAfterLocalTaskAdd, nextTotalAfterLocalTaskRemoval, snapFabPosition, normalizeRequiredName, numericHabitInputValue, performHabitRestore, performTrashMutation, previousHabitSwipeValue, quickTaskFields, readCountdownCache, readHabitGridCache, readStoredBoolean, readStoredNavigation, shouldToggleRowSwipe, validateHabitForm, writeCountdownCache, writeHabitGridCache, writeStoredBoolean, writeStoredNavigation } from './mvp-utils'
import { archivePanelFlags, calendarModeLabel, changedHabitFields, clampFabPosition, countdownDayText, countdownKindLabel, dateKey, dayBefore, defaultView, fabBottomReserved, formatArchivedAt, formatAuditAction, formatAuditEntity, formatHabitApiError, formatHabitHistoryNumber, formatLocalShortDateTime, formatUserAgent, habitActionState, habitButtonNotice, habitButtonValue, habitHistoryWindow, habitWeek, invalidateHabitGridCache, isFabDrag, isHabitComplete, isHabitScheduledToday, isTaskView, mergeHabitHistory, nextHabitSwipeValue, nextTotalAfterLocalTaskAdd, nextTotalAfterLocalTaskRemoval, snapFabPosition, normalizeRequiredName, numericHabitInputValue, performHabitRestore, performTrashMutation, previousHabitSwipeValue, quickTaskFields, readCountdownCache, readHabitGridCache, readStoredBoolean, readStoredNavigation, shouldToggleRowSwipe, validateHabitForm, writeCountdownCache, writeHabitGridCache, writeStoredBoolean, writeStoredNavigation } from './mvp-utils'
describe('MVP view utilities', () => {
it('formats a local date as YYYY-MM-DD', () => {
@@ -202,6 +202,12 @@ describe('MVP view utilities', () => {
expect(clampFabPosition(120, 300, 390, 844)).toEqual({ x: 120, y: 300 })
})
it('reserves the paper navigation, safe area, and gap below a dragged add button', () => {
expect(fabBottomReserved(0)).toBe(76)
expect(fabBottomReserved(34)).toBe(110)
expect(clampFabPosition(500, 900, 390, 844, 56, 14, fabBottomReserved(34))).toEqual({ x: 320, y: 678 })
})
it('snaps the add button to the nearest horizontal edge while preserving its safe vertical position', () => {
expect(snapFabPosition(40, 300, 390, 844, 56, 14, 84)).toEqual({ x: 14, y: 300 })
expect(snapFabPosition(280, 300, 390, 844, 56, 14, 84)).toEqual({ x: 320, y: 300 })
+4
View File
@@ -190,6 +190,10 @@ export async function performTrashMutation(
return { mutated: true, refreshed: await refresh() }
}
export function fabBottomReserved(safeAreaBottom = 0, navigationHeight = 64, gap = 12) {
return navigationHeight + Math.max(0, safeAreaBottom) + gap
}
export function clampFabPosition(x: number, y: number, viewportWidth: number, viewportHeight: number, size = 56, margin = 14, bottomReserved = 84) {
return {
x: Math.min(Math.max(x, margin), Math.max(margin, viewportWidth - size - margin)),
+2 -2
View File
@@ -1,7 +1,7 @@
@import "tailwindcss";
@import "./today-sections.css";
:root{font-family:-apple-system,BlinkMacSystemFont,"Avenir Next","PingFang SC","Hiragino Sans GB","Segoe UI",sans-serif;color:#2d2924;background:#f8f3e8;--accent:#f15a29;--accent-soft:#fbe6dc;--paper:#fffdf8;--sidebar:#f6f0e4;--line:#e7ddcc;--muted:#8b8275;--danger:#bd3827;--shadow:0 12px 36px rgba(78,58,34,.10);--sheet-radius:20px;--sheet-scrim:rgba(45,38,31,.4)}
:root{font-family:-apple-system,BlinkMacSystemFont,"Avenir Next","PingFang SC","Hiragino Sans GB","Segoe UI",sans-serif;color:#2d2924;background:#f8f3e8;--accent:#f15a29;--accent-soft:#fbe6dc;--paper:#fffdf8;--sidebar:#f6f0e4;--line:#e7ddcc;--muted:#8b8275;--danger:#bd3827;--shadow:0 12px 36px rgba(78,58,34,.10);--sheet-radius:20px;--sheet-scrim:rgba(45,38,31,.4);--safe-area-bottom:env(safe-area-inset-bottom)}
*{box-sizing:border-box}html{-webkit-text-size-adjust:100%;text-size-adjust:100%}body{margin:0;background:#f8f3e8}button,input,textarea,select{font:inherit;color:inherit}@media(max-width:800px){input,textarea,select{font-size:16px}}button{cursor:pointer}svg{width:18px;height:18px;stroke-width:1.8}.center,.auth-shell{min-height:100vh;display:grid;place-items:center}.center{gap:12px;align-content:center;color:var(--muted)}.loading-brand{grid-template-columns:auto;justify-items:center;gap:13px}.loading-logo{width:40px;height:40px;display:block;margin-bottom:3px}.loading-brand>span:last-child{font-size:13px}.loader{width:24px;height:24px;border:2px solid var(--line);border-top-color:var(--accent);border-radius:50%;animation:spin .8s linear infinite}@keyframes spin{to{transform:rotate(360deg)}}
.auth-shell{background:var(--surface-canvas)}.auth-card{width:min(390px,90vw);padding:38px;background:var(--paper);border:1px solid var(--line);border-radius:14px;box-shadow:var(--shadow);display:grid;gap:16px}.brand{display:inline-flex;align-items:center;gap:11px;font-size:40px;font-weight:850;letter-spacing:-3px}.brand-wordmark{color:var(--text-primary)}.brand-logo{width:42px;height:42px;flex:0 0 auto;display:block}.brand.small{font-size:29px;gap:9px}.brand.small .brand-logo{width:32px;height:32px}.auth-card p{margin:0 0 8px;color:var(--muted)}.auth-card label,.detail-form>label{display:grid;gap:7px;font-size:12px;font-weight:650;color:#756d61}.auth-card input,.detail-form input,.detail-form select{width:100%;border:1px solid var(--line);background:#fff;padding:11px;border-radius:9px;outline:none}.auth-card input:focus,.detail-form input:focus,.detail-form select:focus,.detail-form textarea:focus{border-color:var(--accent);box-shadow:0 0 0 3px rgba(241,90,41,.1)}.primary{border:0;background:var(--accent);color:#fff;padding:12px;border-radius:9px;font-weight:700;box-shadow:0 5px 12px rgba(241,90,41,.2)}.auth-card small{color:var(--danger)}
.shell{height:100vh;display:grid;grid-template-columns:236px minmax(430px,1fr) 0;background:var(--paper);overflow:hidden;transition:grid-template-columns .22s ease}.shell.detail-open{grid-template-columns:236px minmax(430px,1fr) 350px}.shell.sidebar-collapsed{grid-template-columns:0 minmax(430px,1fr) 0}.shell.sidebar-collapsed.detail-open{grid-template-columns:0 minmax(430px,1fr) 350px}.shell.sidebar-collapsed .sidebar{width:0;min-width:0;overflow:hidden;border-right:0;padding:0}.sidebar{border-right:1px solid #e4d5c3;background:#f7efe3;display:flex;flex-direction:column;min-height:0;overflow:hidden}.brand-row{height:68px;padding:0 20px;display:flex;align-items:center;justify-content:space-between;border-bottom:1px solid rgba(222,205,185,.75)}.primary-nav{display:grid;padding:10px 11px 8px;gap:2px}.primary-nav button,.sidebar-management button{position:relative;display:flex;align-items:center;gap:10px;border:0;background:transparent;padding:0 12px;min-height:44px;border-radius:11px;color:#655b50;text-align:left}.primary-nav button:hover,.sidebar-management button:hover,.list-row:hover,.folder-row>button:hover{background:rgba(255,255,255,.68)}.primary-nav button.active{background:#f9e2d5;color:#b63c1b;font-weight:750}.list-row.active{background:#fff8ef;color:#bc431f;box-shadow:inset 0 0 0 1px #ead5c4}.sidebar-management button.active{background:#f9e2d5;color:#b63c1b;font-weight:750}.primary-nav button.active::before,.list-row.active::before,.sidebar-management button.active::before{content:"";position:absolute;left:2px;top:50%;width:3px;height:18px;border-radius:999px;background:var(--accent);transform:translateY(-50%)}.section-title{padding:14px 15px 5px 20px;display:flex;justify-content:space-between;align-items:center;color:#958b7d;text-transform:uppercase;letter-spacing:.08em;font-size:11px;font-weight:750}.section-title>span:last-child{display:flex}.mini-icon,.row-actions button{min-width:44px;min-height:44px;border:0;background:transparent;padding:4px;color:#8e8477}.mini-icon svg,.row-actions svg{width:14px;height:14px}.folders{flex:1;min-height:0;padding:0 10px;overflow-y:auto;overflow-x:hidden;-webkit-overflow-scrolling:touch;scrollbar-width:none}.folders::-webkit-scrollbar{display:none}.folder-row,.list-row{position:relative;display:flex;align-items:center;border-radius:8px}.folder-row>button,.list-row-main{flex:1;min-width:0;min-height:44px;border:0;background:transparent;padding:0 8px;display:flex;align-items:center;gap:7px;color:#6d6559;text-align:left}.folder-row>button>span,.list-row-main>span{min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.folder-row>button svg{width:14px}.row-actions{display:flex;position:absolute;right:0;opacity:0;pointer-events:none;transition:opacity .15s}.folder-row:hover .row-actions,.folder-row:focus-within .row-actions,.list-row:hover .row-actions,.list-row:focus-within .row-actions{opacity:1;pointer-events:auto}.list-row-main{width:100%;min-height:44px;padding:8px 7px 8px 31px;color:#665f55}.draggable-list-row-main{padding-left:0}.list-row.active .list-row-main{color:inherit}.sidebar-create-wrap{position:relative}.sidebar-popover{position:absolute;z-index:12;display:grid;min-width:148px;padding:6px;background:#fffdf8;border:1px solid var(--line);border-radius:12px;box-shadow:0 12px 28px rgba(78,58,34,.14);text-transform:none;letter-spacing:0}.sidebar-create-menu{top:40px;right:0}.sidebar-popover button{min-height:38px;border:0;background:transparent;border-radius:8px;padding:7px 10px;display:flex;align-items:center;gap:8px;color:#665e52;text-align:left;white-space:nowrap;font-size:12px}.sidebar-popover button:hover{background:#fbf0e8}.sidebar-popover svg{width:15px;height:15px}.sidebar-action-mask{position:fixed;z-index:70;inset:0;display:flex;align-items:flex-end}.sidebar-management{display:grid;gap:2px;margin:8px 12px max(12px,env(safe-area-inset-bottom));padding-top:8px;border-top:1px solid rgba(218,207,190,.82)}.mobile-only,.bottom{display:none}.scrim{display:none}
@@ -82,7 +82,7 @@ input,select,textarea,.search{background:var(--surface-raised);border-color:var(
.empty,.center{color:var(--muted)}.loader{background:var(--surface-raised)}
.toast{background:#3b342c;color:#fff;border:1px solid #574d42;border-radius:var(--radius-control);box-shadow:var(--shadow-raised)}.error-toast{background:var(--danger);color:#fff;border:1px solid #9f2f22;border-radius:var(--radius-control);box-shadow:var(--shadow-raised)}
:focus-visible{outline:3px solid var(--focus-ring);outline-offset:2px}.task-check:focus-visible,.archived-lists-toggle:focus-visible,.archived-row-menu-trigger:focus-visible,.archived-row-actions button:focus-visible{outline:3px solid var(--focus-ring);outline-offset:2px}
@media(max-width:930px){.bottom{left:12px;right:12px;bottom:max(10px,env(safe-area-inset-bottom));background:var(--surface-raised);border:1px solid var(--border-cream);border-radius:20px;padding:7px 5px;box-shadow:var(--highlight-inner),var(--shadow-raised)}.bottom button{min-height:44px;border-radius:11px}.bottom button.active{background:#fbe6dc}.sidebar{border-radius:0 var(--radius-panel) var(--radius-panel) 0}.detail,.app-sheet,.task-compose-sheet,.habit-detail-sheet,.countdown-detail-sheet{border-radius:var(--sheet-radius) var(--sheet-radius) 0 0!important}.task-list,.habit-list,.countdown-timeline{display:grid;gap:0}.task-row,.habit-row,.countdown-row{min-height:62px;background:var(--surface-raised);border:0;border-radius:0;box-shadow:none}.task-row+.task-row,.habit-row+.habit-row,.countdown-row+.countdown-row{border-top:1px solid var(--border-cream)}.countdown-row:first-of-type{border-top:0}.unified-fab{bottom:calc(88px + env(safe-area-inset-bottom))}}
@media(max-width:930px){.bottom{left:0;right:0;bottom:0;height:calc(64px + var(--safe-area-bottom));display:grid;grid-template-columns:repeat(4,minmax(0,1fr));background:var(--surface-raised);border:0;border-top:1px solid var(--border-cream);border-radius:0;padding:4px 10px var(--safe-area-bottom);box-shadow:none}.bottom button{position:relative;min-width:0;min-height:44px;border-radius:0;padding:2px 4px;line-height:1.1}.bottom button svg{width:19px;height:19px}.bottom button.active{background:transparent;color:var(--accent)}.bottom button.active:before{content:"";position:absolute;left:23%;right:23%;top:-5px;height:3px;border-radius:0 0 3px 3px;background:var(--accent)}.sidebar{border-radius:0 var(--radius-panel) var(--radius-panel) 0}.detail,.app-sheet,.task-compose-sheet,.habit-detail-sheet,.countdown-detail-sheet{border-radius:var(--sheet-radius) var(--sheet-radius) 0 0!important}.task-list,.habit-list,.countdown-timeline{display:grid;gap:0}.task-row,.habit-row,.countdown-row{min-height:62px;background:var(--surface-raised);border:0;border-radius:0;box-shadow:none}.task-row+.task-row,.habit-row+.habit-row,.countdown-row+.countdown-row{border-top:1px solid var(--border-cream)}.countdown-row:first-of-type{border-top:0}.unified-fab{bottom:calc(76px + var(--safe-area-bottom))}}
@media(prefers-reduced-motion:reduce){*,*:before,*:after{scroll-behavior:auto!important;animation-duration:.01ms!important;transition-duration:.01ms!important}.completed-filter-pill,.completed-filter-pill__track,.completed-filter-pill__thumb{transition:none!important}.topbar-refresh.spinning svg{animation:none!important}.completed-filter-pill:active:not(:disabled){transform:none}.task-row.just-completed,.habit-row.just-completed,.task-row.just-completed .task-check-mark,.habit-row.just-completed .task-check-mark{animation:none}}
/* Approved five-detail list/search polish. */
@media(min-width:931px){.task-search-toggle{display:none}.search-reveal{min-width:320px}}
+6 -3
View File
@@ -204,8 +204,11 @@ describe('solid cream material system', () => {
it('keeps controls solid, focus visible, mobile targets safe, and motion reducible', () => {
expect(css).toContain('input,select,textarea,.search{background:var(--surface-raised)')
expect(css).toContain(':focus-visible{outline:3px solid var(--focus-ring)')
expect(css).toContain('@media(max-width:930px){.bottom{left:12px;right:12px;bottom:max(10px,env(safe-area-inset-bottom));')
expect(css).toContain('min-height:44px')
expect(css).toContain('@media(max-width:930px){.bottom{left:0;right:0;bottom:0;height:calc(64px + var(--safe-area-bottom));')
expect(css).toContain('border:0;border-top:1px solid var(--border-cream);border-radius:0;padding:4px 10px var(--safe-area-bottom)')
expect(css).toMatch(/\.bottom button\{[^}]*min-height:44px/)
expect(css).toMatch(/\.bottom button\.active\{[^}]*background:transparent/)
expect(css).toContain('.bottom button.active:before{content:"";position:absolute;left:23%;right:23%;top:-5px;height:3px;')
expect(css).toContain('@media(prefers-reduced-motion:reduce)')
})
})
@@ -1193,7 +1196,7 @@ describe('unified floating add interaction', () => {
expect(css).toContain('.unified-fab.snapping')
expect(countdownPanel).toContain('<AppSheet :open="open" variant="create"')
expect(countdownPanel).toContain('panel-class="countdown-modal"')
expect(css).toContain('@media(max-width:930px){.unified-fab{bottom:calc(82px + env(safe-area-inset-bottom))}')
expect(css).toMatch(/@media\(max-width:930px\)\{[^}]*\.bottom\{[^}]*height:calc\(64px \+ var\(--safe-area-bottom\)\)[\s\S]*?\.unified-fab\{bottom:calc\(76px \+ var\(--safe-area-bottom\)\)\}/)
})
it('renders the original orange circular plus button', () => {