feat: use swipe to complete items
ci / docker (push) Successful in 3m22s

This commit is contained in:
2026-09-06 08:12:44 +08:00
parent 35e562026c
commit 13715945d5
10 changed files with 69 additions and 25 deletions
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+2 -2
View File
@@ -1,3 +1,3 @@
<!doctype html><html lang="zh-CN"><head><meta charset="UTF-8"><meta name="viewport" content="width=device-width,initial-scale=1,viewport-fit=cover"><meta name="theme-color" content="#f15a29"><link rel="manifest" href="/manifest.json"><title>dodo</title> <script type="module" crossorigin src="/assets/index-CmIuUToo.js"></script> <!doctype html><html lang="zh-CN"><head><meta charset="UTF-8"><meta name="viewport" content="width=device-width,initial-scale=1,viewport-fit=cover"><meta name="theme-color" content="#f15a29"><link rel="manifest" href="/manifest.json"><title>dodo</title> <script type="module" crossorigin src="/assets/index-VdV4G6jw.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-nhCaMkMR.css"> <link rel="stylesheet" crossorigin href="/assets/index-CPHlHtC0.css">
</head><body><div id="app"></div></body></html> </head><body><div id="app"></div></body></html>
+20 -4
View File
@@ -6,7 +6,7 @@ import {
Settings, Trash2, X, Repeat2, Settings, Trash2, X, Repeat2,
} from 'lucide-vue-next' } from 'lucide-vue-next'
import { filterTasks, fromDateTimeLocal, groupTaskTree, renderMarkdown, toDateTimeLocal } from './lib/task-utils' import { filterTasks, fromDateTimeLocal, groupTaskTree, renderMarkdown, toDateTimeLocal } from './lib/task-utils'
import { isTaskView } from './lib/mvp-utils' import { isTaskView, shouldToggleHabitSwipe } from './lib/mvp-utils'
import { csrfHeader } from './lib/csrf' import { csrfHeader } from './lib/csrf'
import MvpPanel from './MvpPanel.vue' import MvpPanel from './MvpPanel.vue'
@@ -43,6 +43,7 @@ const totalTasks = ref(0)
const totalPages = computed(() => Math.max(1, Math.ceil(totalTasks.value / pageSize))) const totalPages = computed(() => Math.max(1, Math.ceil(totalTasks.value / pageSize)))
const expandedFolders = ref(new Set<string>()) const expandedFolders = ref(new Set<string>())
const navigationLoaded = ref(false) const navigationLoaded = ref(false)
const taskSwipeStart = ref<{ id: string; x: number; y: number } | null>(null)
const activeName = computed(() => { const activeName = computed(() => {
if (activeView.value === 'trash') return '回收站' if (activeView.value === 'trash') return '回收站'
@@ -221,6 +222,21 @@ async function patchTask(task: Task, patch: Partial<Task>) {
async function toggle(task: Task) { async function toggle(task: Task) {
try { await patchTask(task, { completed: !task.completed }); toast(task.completed ? '已重新打开' : '完成啦') } catch (reason) { fail(reason) } try { await patchTask(task, { completed: !task.completed }); toast(task.completed ? '已重新打开' : '完成啦') } catch (reason) { fail(reason) }
} }
function startTaskSwipe(task: Task, event: TouchEvent) {
if (activeView.value === 'trash' || loading.value) return
const touch = event.touches[0]
if (touch) taskSwipeStart.value = { id: task.id, x: touch.clientX, y: touch.clientY }
}
async function finishTaskSwipe(task: Task, event: TouchEvent) {
const start = taskSwipeStart.value
taskSwipeStart.value = null
if (!start || start.id !== task.id || activeView.value === 'trash' || loading.value) return
const touch = event.changedTouches[0]
if (touch && shouldToggleHabitSwipe(touch.clientX - start.x, touch.clientY - start.y)) await toggle(task)
}
function cancelTaskSwipe() {
taskSwipeStart.value = null
}
async function saveTask() { async function saveTask() {
if (!selectedTask.value?.title.trim()) return if (!selectedTask.value?.title.trim()) return
try { try {
@@ -341,15 +357,15 @@ onMounted(bootstrap)
<div v-if="activeView!=='trash' && totalPages > 1" class="pager"><button class="secondary" :disabled="page<=1 || loading" @click="previousPage">上一页</button><span>{{page}} / {{totalPages}}</span><button class="secondary" :disabled="page>=totalPages || loading" @click="nextPage">下一页</button></div> <div v-if="activeView!=='trash' && totalPages > 1" class="pager"><button class="secondary" :disabled="page<=1 || loading" @click="previousPage">上一页</button><span>{{page}} / {{totalPages}}</span><button class="secondary" :disabled="page>=totalPages || loading" @click="nextPage">下一页</button></div>
<section class="task-list" :class="{loading}"> <section class="task-list" :class="{loading}">
<template v-for="node in taskTree" :key="node.task.id"> <template v-for="node in taskTree" :key="node.task.id">
<article class="task-row" :class="{done:node.task.completed,selected:selectedTask?.id===node.task.id}"> <article class="task-row swipeable" :class="{done:node.task.completed,selected:selectedTask?.id===node.task.id}" @touchstart.passive="startTaskSwipe(node.task, $event)" @touchend="finishTaskSwipe(node.task, $event)" @touchcancel="cancelTaskSwipe">
<button v-if="activeView!=='trash'" class="check" :class="`p${node.task.priority}`" aria-label="切换完成状态" @click.stop="toggle(node.task)"><Check v-if="node.task.completed"/></button>
<button class="task-main" @click="activeView==='trash'?undefined:selectTask(node.task)"><strong>{{node.task.title}}</strong><span class="meta"><span v-if="node.task.due_at"><CalendarDays/>{{formatDue(node.task.due_at)}}</span><span v-if="node.subtasks.length"><ListChecks/>{{node.subtasks.filter(t=>t.completed).length}}/{{node.subtasks.length}}</span><i v-for="tag in node.task.tags" :key="tag.id" class="tag-dot" :style="{background:tag.color}" :title="tag.name"/></span></button> <button class="task-main" @click="activeView==='trash'?undefined:selectTask(node.task)"><strong>{{node.task.title}}</strong><span class="meta"><span v-if="node.task.due_at"><CalendarDays/>{{formatDue(node.task.due_at)}}</span><span v-if="node.subtasks.length"><ListChecks/>{{node.subtasks.filter(t=>t.completed).length}}/{{node.subtasks.length}}</span><i v-for="tag in node.task.tags" :key="tag.id" class="tag-dot" :style="{background:tag.color}" :title="tag.name"/></span></button>
<span v-if="activeView!=='trash'" class="task-swipe-hint">{{ node.task.completed ? '已完成' : '右滑' }}</span>
<span v-if="node.task.priority" class="priority" :class="`p${node.task.priority}`">{{['','','',''][node.task.priority]}}</span> <span v-if="node.task.priority" class="priority" :class="`p${node.task.priority}`">{{['','','',''][node.task.priority]}}</span>
<button v-if="activeView==='trash'" class="restore" @click="restoreTask(node.task)"><ArchiveRestore/>恢复</button> <button v-if="activeView==='trash'" class="restore" @click="restoreTask(node.task)"><ArchiveRestore/>恢复</button>
<button v-else class="icon ghost" aria-label="删除任务" @click.stop="removeTask(node.task)"><Trash2/></button> <button v-else class="icon ghost" aria-label="删除任务" @click.stop="removeTask(node.task)"><Trash2/></button>
<button v-if="activeView==='trash'" class="icon danger ghost" aria-label="永久删除" @click.stop="purgeTask(node.task)"><X/></button> <button v-if="activeView==='trash'" class="icon danger ghost" aria-label="永久删除" @click.stop="purgeTask(node.task)"><X/></button>
</article> </article>
<article v-for="subtask in node.subtasks" :key="subtask.id" class="task-row subtask" :class="{done:subtask.completed}"><GripVertical/><button class="check" @click="toggle(subtask)"><Check v-if="subtask.completed"/></button><button class="task-main" @click="selectTask(subtask)"><strong>{{subtask.title}}</strong></button></article> <article v-for="subtask in node.subtasks" :key="subtask.id" class="task-row subtask swipeable" :class="{done:subtask.completed}" @touchstart.passive="startTaskSwipe(subtask, $event)" @touchend="finishTaskSwipe(subtask, $event)" @touchcancel="cancelTaskSwipe"><GripVertical/><button class="task-main" @click="selectTask(subtask)"><strong>{{subtask.title}}</strong></button><span class="task-swipe-hint">{{ subtask.completed ? '已完成' : '右滑' }}</span></article>
</template> </template>
<div v-if="!visibleTasks.length&&!loading" class="empty"><ListTodo/><b>{{query?'没有匹配的任务':'这里还很安静'}}</b><span>{{query?'换个关键词试试':'写下第一件想完成的小事吧'}}</span></div> <div v-if="!visibleTasks.length&&!loading" class="empty"><ListTodo/><b>{{query?'没有匹配的任务':'这里还很安静'}}</b><span>{{query?'换个关键词试试':'写下第一件想完成的小事吧'}}</span></div>
</section> </section>
+25 -9
View File
@@ -1,7 +1,7 @@
<script setup lang="ts"> <script setup lang="ts">
import { onBeforeUnmount, onMounted, ref } from 'vue' import { onBeforeUnmount, onMounted, ref } from 'vue'
import { Activity, ArchiveRestore, Check, Download, FileJson, LogOut, Plus, RefreshCw, Trash2, Upload } from 'lucide-vue-next' import { Activity, ArchiveRestore, Download, FileJson, LogOut, Plus, RefreshCw, Trash2, Upload } from 'lucide-vue-next'
import { dateKey, isHabitComplete, mergePage, numericHabitInputValue } from './lib/mvp-utils' import { dateKey, isHabitComplete, mergePage, numericHabitInputValue, shouldToggleHabitSwipe } from './lib/mvp-utils'
import { csrfHeader } from './lib/csrf' import { csrfHeader } from './lib/csrf'
type View = 'habits' | 'settings' type View = 'habits' | 'settings'
@@ -20,8 +20,9 @@ const habitTarget = ref(1)
const importFile = ref<File | null>(null) const importFile = ref<File | null>(null)
const importPreview = ref<any>(null) const importPreview = ref<any>(null)
const restoreFile = ref<File | null>(null) const restoreFile = ref<File | null>(null)
const numericValues = ref<Record<string, number>>({}) const numericValues = ref<Record<string, number | string>>({})
const todayKey = ref(dateKey(new Date())) const todayKey = ref(dateKey(new Date()))
const habitSwipeStart = ref<{ id: string; x: number; y: number } | null>(null)
let dayRolloverTimer: ReturnType<typeof setInterval> | undefined let dayRolloverTimer: ReturnType<typeof setInterval> | undefined
function formatErrorMessage(detail: unknown): string { function formatErrorMessage(detail: unknown): string {
@@ -70,6 +71,23 @@ async function toggleHabit(h: Habit, day: string) {
emit('notice', next ? '打卡成功 🎉' : '已取消打卡') emit('notice', next ? '打卡成功 🎉' : '已取消打卡')
}) })
} }
function startHabitSwipe(h: Habit, event: TouchEvent) {
if (h.kind === 'numeric' || busy.value) return
const touch = event.touches[0]
if (touch) habitSwipeStart.value = { id: h.id, x: touch.clientX, y: touch.clientY }
}
async function finishHabitSwipe(h: Habit, event: TouchEvent) {
const start = habitSwipeStart.value
habitSwipeStart.value = null
if (!start || start.id !== h.id || h.kind === 'numeric' || busy.value) return
const touch = event.changedTouches[0]
if (touch && shouldToggleHabitSwipe(touch.clientX - start.x, touch.clientY - start.y)) {
await toggleHabit(h, todayKey.value)
}
}
function cancelHabitSwipe() {
habitSwipeStart.value = null
}
async function recordNumeric(h: Habit, day: string) { async function recordNumeric(h: Habit, day: string) {
if (busy.value) return if (busy.value) return
const next = numericHabitInputValue(numericValues.value[h.id]) const next = numericHabitInputValue(numericValues.value[h.id])
@@ -185,14 +203,12 @@ onBeforeUnmount(() => {
</form> </form>
<!-- 习惯列表展示和操作分离只点右侧明确按钮降低误触 --> <!-- 习惯列表布尔习惯整行右滑打卡数值习惯保留明确输入记录 -->
<div class="habit-list"> <div class="habit-list">
<article v-for="h in habits" :key="h.id" class="habit-row" :class="{ done: isDone(h, todayKey) }"> <article v-for="h in habits" :key="h.id" class="habit-row" :class="{ done: isDone(h, todayKey), swipeable: h.kind !== 'numeric' }" @touchstart.passive="startHabitSwipe(h, $event)" @touchend="finishHabitSwipe(h, $event)" @touchcancel="cancelHabitSwipe">
<div v-if="h.kind !== 'numeric'" class="habit-main"> <div v-if="h.kind !== 'numeric'" class="habit-main">
<span><span class="habit-name">{{ h.name }}</span><small>{{ isDone(h, todayKey) ? '今天已打卡' : '今天还没做' }}</small></span> <span><span class="habit-name">{{ h.name }}</span><small>{{ isDone(h, todayKey) ? '今天已完成 · 右滑可取消' : '右滑整行打卡' }}</small></span>
<button class="habit-check-button" :class="{ done: isDone(h, todayKey) }" :aria-label="isDone(h, todayKey) ? `取消${h.name}今天的打卡` : `完成${h.name}今天的打卡`" :aria-pressed="isDone(h, todayKey)" @click="toggleHabit(h, todayKey)"> <span class="habit-swipe-hint" aria-hidden="true">{{ isDone(h, todayKey) ? '已完成' : '右滑' }}</span>
<Check v-if="isDone(h, todayKey)" />
</button>
</div> </div>
<div v-else class="habit-main numeric-habit"> <div v-else class="habit-main numeric-habit">
<span><span class="habit-name">{{ h.name }}</span><small>今天 {{ logFor(h, todayKey)?.value || 0 }} / {{ h.target || 1 }}{{ h.unit || '' }}</small></span> <span><span class="habit-name">{{ h.name }}</span><small>今天 {{ logFor(h, todayKey)?.value || 0 }} / {{ h.target || 1 }}{{ h.unit || '' }}</small></span>
+8 -1
View File
@@ -1,5 +1,5 @@
import { describe, expect, it } from 'vitest' import { describe, expect, it } from 'vitest'
import { dateKey, habitWeek, isHabitComplete, isTaskView, numericHabitInputValue } from './mvp-utils' import { dateKey, habitWeek, isHabitComplete, isTaskView, numericHabitInputValue, shouldToggleHabitSwipe } from './mvp-utils'
describe('MVP view utilities', () => { describe('MVP view utilities', () => {
it('formats a local date as YYYY-MM-DD', () => { it('formats a local date as YYYY-MM-DD', () => {
@@ -28,4 +28,11 @@ describe('MVP view utilities', () => {
expect(numericHabitInputValue('')).toBeNull() expect(numericHabitInputValue('')).toBeNull()
expect(numericHabitInputValue(4)).toBe(4) expect(numericHabitInputValue(4)).toBe(4)
}) })
it('toggles a habit only for a deliberate horizontal swipe', () => {
expect(shouldToggleHabitSwipe(78, 8)).toBe(true)
expect(shouldToggleHabitSwipe(-78, 8)).toBe(false)
expect(shouldToggleHabitSwipe(30, 2)).toBe(false)
expect(shouldToggleHabitSwipe(80, 35)).toBe(false)
})
}) })
+4
View File
@@ -34,3 +34,7 @@ export function numericHabitInputValue(input: number | string | undefined) {
const value = Number(input) const value = Number(input)
return Number.isFinite(value) && value >= 0 ? value : null return Number.isFinite(value) && value >= 0 ? value : null
} }
export function shouldToggleHabitSwipe(deltaX: number, deltaY: number) {
return deltaX >= 64 && Math.abs(deltaY) <= 24 && deltaX > Math.abs(deltaY) * 1.5
}
+4 -3
View File
@@ -4,7 +4,7 @@
*{box-sizing:border-box}body{margin:0;background:#f8f3e8}button,input,textarea,select{font:inherit;color:inherit}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)}.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)}} *{box-sizing:border-box}body{margin:0;background:#f8f3e8}button,input,textarea,select{font:inherit;color:inherit}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)}.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:radial-gradient(circle at 20% 10%,#ffe3d5 0,transparent 28%),linear-gradient(135deg,#f8f3e8,#fffaf0)}.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{font-size:40px;font-weight:850;letter-spacing:-3px}.brand span{color:var(--accent)}.brand.small{font-size:29px}.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)} .auth-shell{background:radial-gradient(circle at 20% 10%,#ffe3d5 0,transparent 28%),linear-gradient(135deg,#f8f3e8,#fffaf0)}.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{font-size:40px;font-weight:850;letter-spacing:-3px}.brand span{color:var(--accent)}.brand.small{font-size:29px}.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:238px minmax(430px,1fr) 350px;background:var(--paper);overflow:hidden}.sidebar{border-right:1px solid var(--line);background:var(--sidebar);display:flex;flex-direction:column;min-height:0}.brand-row{height:76px;padding:0 20px;display:flex;align-items:center;justify-content:space-between}.primary-nav{display:grid;padding:4px 12px 12px;gap:3px}.primary-nav button,.settings{display:flex;align-items:center;gap:10px;border:0;background:transparent;padding:10px 12px;border-radius:9px;color:#665e52;text-align:left}.primary-nav button:hover,.list-row:hover,.folder-row>button:hover{background:rgba(255,255,255,.52)}.primary-nav button.active,.list-row.active{background:var(--accent-soft);color:#b7421e;font-weight:700}.section-title{padding:17px 17px 7px 22px;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{border:0;background:transparent;padding:4px;color:#8e8477}.mini-icon svg,.row-actions svg{width:14px;height:14px}.folders{padding:0 10px;overflow:auto}.folder-row{display:flex;align-items:center}.folder-row>button{flex:1;min-width:0;border:0;background:transparent;padding:8px;display:flex;align-items:center;gap:7px;color:#6d6559;text-align:left}.folder-row>button svg{width:14px}.row-actions{display:flex;opacity:0;transition:opacity .15s}.folder-row:hover .row-actions,.list-row:hover .row-actions{opacity:1}.list-row{width:100%;border:0;background:transparent;padding:8px 7px 8px 31px;border-radius:8px;display:flex;align-items:center;gap:9px;text-align:left;color:#665f55}.list-row>span:nth-child(2){flex:1;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.list-row i{width:8px;height:8px;background:#d89b62;border-radius:3px}.settings{margin:auto 12px 14px}.mobile-only,.bottom,.fab{display:none} .shell{height:100vh;display:grid;grid-template-columns:238px minmax(430px,1fr) 350px;background:var(--paper);overflow:hidden}.sidebar{border-right:1px solid var(--line);background:var(--sidebar);display:flex;flex-direction:column;min-height:0}.brand-row{height:76px;padding:0 20px;display:flex;align-items:center;justify-content:space-between}.primary-nav{display:grid;padding:4px 12px 12px;gap:3px}.primary-nav button,.settings{display:flex;align-items:center;gap:10px;border:0;background:transparent;padding:10px 12px;border-radius:9px;color:#665e52;text-align:left}.primary-nav button:hover,.list-row:hover,.folder-row>button:hover{background:rgba(255,255,255,.52)}.primary-nav button.active,.list-row.active{background:var(--accent-soft);color:#b7421e;font-weight:700}.section-title{padding:17px 17px 7px 22px;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{border:0;background:transparent;padding:4px;color:#8e8477}.mini-icon svg,.row-actions svg{width:14px;height:14px}.folders{padding:0 10px;overflow:auto}.folder-row{display:flex;align-items:center}.folder-row>button{flex:1;min-width:0;border:0;background:transparent;padding:8px;display:flex;align-items:center;gap:7px;color:#6d6559;text-align:left}.folder-row>button svg{width:14px}.row-actions{display:flex;opacity:0;transition:opacity .15s}.folder-row:hover .row-actions,.list-row:hover .row-actions{opacity:1}.list-row{width:100%;border:0;background:transparent;padding:8px 7px 8px 31px;border-radius:8px;display:flex;align-items:center;gap:9px;text-align:left;color:#665f55}.list-row>span:nth-child(2){flex:1;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.list-row i{width:8px;height:8px;background:#d89b62;border-radius:3px}.settings{margin:auto 12px 14px}.mobile-only,.bottom,.fab{display:none}
main{min-width:0;padding:27px 34px 50px;overflow:auto;background:linear-gradient(rgba(255,253,248,.92),rgba(255,253,248,.92)),repeating-linear-gradient(0deg,transparent,transparent 31px,#eee3d2 32px)}.topbar{display:flex;align-items:center;gap:14px}.topbar>div{flex:1}.topbar p{margin:0;color:var(--muted);font-size:12px}.topbar h1{font-size:27px;margin:3px 0 21px;letter-spacing:-.03em}.search{margin-bottom:18px;display:flex;align-items:center;gap:8px;width:min(260px,36%);padding:8px 10px;background:#faf7f0;border:1px solid var(--line);border-radius:9px;color:var(--muted)}.search input{min-width:0;width:100%;border:0;outline:0;background:transparent}.search kbd{font-size:10px;white-space:nowrap;border:1px solid var(--line);padding:2px 4px;border-radius:4px}.quick{display:flex;align-items:center;gap:10px;background:#fff;border:1px solid var(--line);border-radius:11px;padding:7px 7px 7px 13px;box-shadow:0 2px 10px rgba(81,61,38,.05);transition:.18s}.quick:focus-within{border-color:var(--accent);box-shadow:0 0 0 3px rgba(241,90,41,.09)}.quick>svg{color:var(--accent)}.quick input{border:0;outline:0;background:transparent;min-width:0;flex:1}.quick button{border:0;border-radius:8px;background:var(--accent);color:white;padding:8px 15px;font-weight:650}.list-toolbar{height:42px;display:flex;align-items:center;gap:14px;color:var(--muted);font-size:12px}.list-toolbar label{margin-right:auto}.link{border:0;background:transparent;color:var(--accent);padding:3px;display:inline-flex;gap:3px;align-items:center}.link svg{width:14px}.task-list{transition:opacity .2s}.task-list.loading{opacity:.45}.task-row{min-height:53px;display:flex;align-items:center;gap:10px;border-bottom:1px solid var(--line);padding:4px 8px;transition:background .15s,transform .15s}.task-row:hover,.task-row.selected{background:rgba(250,240,229,.75)}.task-row:hover{transform:translateX(2px)}.check{width:19px;height:19px;flex:0 0 19px;border:1.6px solid #c6baa8;background:#fff;border-radius:5px;padding:0;display:grid;place-items:center}.check svg{width:13px}.check.p1{border-color:#4b93d1}.check.p2{border-color:#d79b25}.check.p3{border-color:#dc4b30}.done .check{background:#afa595;color:white}.task-main{border:0;background:transparent;flex:1;min-width:0;text-align:left;padding:8px 0}.task-main strong{display:block;font-size:14px;font-weight:590;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.done .task-main strong{text-decoration:line-through;color:#9b9388}.meta{display:flex;align-items:center;gap:9px;color:#9b9286;font-size:11px;margin-top:3px}.meta span{display:flex;align-items:center;gap:3px}.meta svg{width:12px}.tag-dot{width:7px;height:7px;border-radius:50%}.priority{font-size:10px;font-weight:750;padding:3px 6px;border-radius:5px}.priority.p1{color:#3f80ba;background:#e5f2fc}.priority.p2{color:#a56b05;background:#fff1cb}.priority.p3{color:#bd3827;background:#fde2dc}.icon,.ghost{border:0;background:transparent;display:grid;place-items:center;padding:5px;border-radius:6px}.ghost{opacity:0;color:#9d9387}.task-row:hover .ghost{opacity:1}.ghost:hover{color:var(--danger);background:#fce7e2}.restore{display:flex;align-items:center;gap:5px;border:1px solid var(--line);background:#fff;border-radius:7px;padding:6px 8px;font-size:12px}.restore svg{width:14px}.subtask{padding-left:53px;min-height:42px;color:#6d655b}.subtask>svg{width:13px;color:#bbb0a2}.empty{min-height:300px;display:grid;place-items:center;align-content:center;gap:8px;color:#aaa094;text-align:center}.empty>svg{width:38px;height:38px;color:#d8cabb}.empty b{color:#6f675c}.empty span{font-size:13px} main{min-width:0;padding:27px 34px 50px;overflow:auto;background:linear-gradient(rgba(255,253,248,.92),rgba(255,253,248,.92)),repeating-linear-gradient(0deg,transparent,transparent 31px,#eee3d2 32px)}.topbar{display:flex;align-items:center;gap:14px}.topbar>div{flex:1}.topbar p{margin:0;color:var(--muted);font-size:12px}.topbar h1{font-size:27px;margin:3px 0 21px;letter-spacing:-.03em}.search{margin-bottom:18px;display:flex;align-items:center;gap:8px;width:min(260px,36%);padding:8px 10px;background:#faf7f0;border:1px solid var(--line);border-radius:9px;color:var(--muted)}.search input{min-width:0;width:100%;border:0;outline:0;background:transparent}.search kbd{font-size:10px;white-space:nowrap;border:1px solid var(--line);padding:2px 4px;border-radius:4px}.quick{display:flex;align-items:center;gap:10px;background:#fff;border:1px solid var(--line);border-radius:11px;padding:7px 7px 7px 13px;box-shadow:0 2px 10px rgba(81,61,38,.05);transition:.18s}.quick:focus-within{border-color:var(--accent);box-shadow:0 0 0 3px rgba(241,90,41,.09)}.quick>svg{color:var(--accent)}.quick input{border:0;outline:0;background:transparent;min-width:0;flex:1}.quick button{border:0;border-radius:8px;background:var(--accent);color:white;padding:8px 15px;font-weight:650}.list-toolbar{height:42px;display:flex;align-items:center;gap:14px;color:var(--muted);font-size:12px}.list-toolbar label{margin-right:auto}.link{border:0;background:transparent;color:var(--accent);padding:3px;display:inline-flex;gap:3px;align-items:center}.link svg{width:14px}.task-list{transition:opacity .2s}.task-list.loading{opacity:.45}.task-row{min-height:53px;display:flex;align-items:center;gap:10px;border-bottom:1px solid var(--line);padding:4px 8px;transition:background .15s,transform .15s}.task-row.swipeable{touch-action:pan-y}.task-row:hover,.task-row.selected{background:rgba(250,240,229,.75)}.task-row:hover{transform:translateX(2px)}.check{width:19px;height:19px;flex:0 0 19px;border:1.6px solid #c6baa8;background:#fff;border-radius:5px;padding:0;display:grid;place-items:center}.check svg{width:13px}.check.p1{border-color:#4b93d1}.check.p2{border-color:#d79b25}.check.p3{border-color:#dc4b30}.done .check{background:#afa595;color:white}.task-main{border:0;background:transparent;flex:1;min-width:0;text-align:left;padding:8px 0}.task-main strong{display:block;font-size:14px;font-weight:590;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.done .task-main strong{text-decoration:line-through;color:#9b9388}.task-swipe-hint{flex:0 0 auto;border-radius:999px;background:#f3ede3;color:#9a8f80;padding:6px 10px;font-size:11px;font-weight:700}.task-row.done .task-swipe-hint{background:var(--accent-soft);color:#b7421e}.meta{display:flex;align-items:center;gap:9px;color:#9b9286;font-size:11px;margin-top:3px}.meta span{display:flex;align-items:center;gap:3px}.meta svg{width:12px}.tag-dot{width:7px;height:7px;border-radius:50%}.priority{font-size:10px;font-weight:750;padding:3px 6px;border-radius:5px}.priority.p1{color:#3f80ba;background:#e5f2fc}.priority.p2{color:#a56b05;background:#fff1cb}.priority.p3{color:#bd3827;background:#fde2dc}.icon,.ghost{border:0;background:transparent;display:grid;place-items:center;padding:5px;border-radius:6px}.ghost{opacity:0;color:#9d9387}.task-row:hover .ghost{opacity:1}.ghost:hover{color:var(--danger);background:#fce7e2}.restore{display:flex;align-items:center;gap:5px;border:1px solid var(--line);background:#fff;border-radius:7px;padding:6px 8px;font-size:12px}.restore svg{width:14px}.subtask{padding-left:53px;min-height:42px;color:#6d655b}.subtask>svg{width:13px;color:#bbb0a2}.empty{min-height:300px;display:grid;place-items:center;align-content:center;gap:8px;color:#aaa094;text-align:center}.empty>svg{width:38px;height:38px;color:#d8cabb}.empty b{color:#6f675c}.empty span{font-size:13px}
.detail{min-width:0;border-left:1px solid var(--line);background:#faf7f0;overflow:auto}.detail-head{height:57px;display:flex;align-items:center;justify-content:space-between;padding:0 21px;border-bottom:1px solid var(--line);font-size:12px;font-weight:700;color:#80766a;text-transform:uppercase;letter-spacing:.08em}.paper{margin:22px;padding:28px 20px;min-height:180px;background:#fff;border:1px solid var(--line);border-radius:11px;box-shadow:0 4px 18px rgba(76,57,34,.05);display:grid;place-items:center;align-content:center;text-align:center;color:#8f8578}.paper svg{width:32px;height:32px;color:#ceb8a4;margin-bottom:12px}.paper b{color:#625b50}.paper p{font-size:13px;line-height:1.6}.detail-form{padding:19px;display:grid;gap:15px}.detail-title{display:flex;align-items:flex-start;gap:10px}.check.large{margin-top:8px;width:22px;height:22px;flex-basis:22px}.detail-title textarea{flex:1;border:0;background:transparent;resize:none;outline:none;font-size:19px;line-height:1.4;font-weight:700}.detail-form>label{grid-template-columns:80px 1fr;align-items:center}.detail-form>label input,.detail-form>label select{padding:8px}.field{display:grid;gap:7px}.field-label{display:flex;justify-content:space-between;align-items:center;font-size:12px;font-weight:700;color:#756d61}.tag-picker{display:flex;flex-wrap:wrap;gap:6px}.tag-picker button{border:1px solid var(--line);background:#fff;border-radius:999px;padding:5px 8px;display:flex;align-items:center;gap:5px;font-size:11px}.tag-picker button.chosen{border-color:#c89077;background:#fff1e9}.tag-picker i{width:8px;height:8px;border-radius:50%}.hint{font-size:12px;color:#a49a8d}.markdown .field-label>span:last-child{display:flex;background:#eee7dc;padding:2px;border-radius:6px}.markdown .field-label button{border:0;background:transparent;padding:4px 8px;border-radius:5px;font-size:11px}.markdown .field-label button.active{background:#fff;color:var(--accent)}.markdown textarea{border:1px solid var(--line);background:#fff;border-radius:9px;padding:11px;resize:vertical;outline:none;font:13px/1.6 ui-monospace,SFMono-Regular,Menlo,monospace}.markdown-preview{min-height:160px;padding:10px 12px;background:#fff;border:1px solid var(--line);border-radius:9px;font-size:13px;line-height:1.65;overflow-wrap:anywhere}.markdown-preview h1{font-size:20px}.markdown-preview h2{font-size:16px}.markdown-preview p{margin:8px 0}.markdown-preview code{background:#f2ece2;padding:2px 4px;border-radius:4px}.markdown-preview a{color:var(--accent)}.subtasks{display:grid;gap:5px}.subtask-detail{display:flex;align-items:center;gap:8px;border:0;background:#fff;padding:8px;border-radius:7px;text-align:left}.subtask-detail .check{pointer-events:none}.strike{text-decoration:line-through;color:var(--muted)}.detail-actions{display:flex;justify-content:space-between;align-items:center;padding-top:10px;border-top:1px solid var(--line)}.secondary{border:1px solid var(--line);background:#fff;padding:8px 11px;border-radius:8px;font-weight:650}.danger-text{border:0;background:transparent;color:var(--danger);display:flex;align-items:center;gap:5px;font-size:12px}.danger-text svg{width:14px} .detail{min-width:0;border-left:1px solid var(--line);background:#faf7f0;overflow:auto}.detail-head{height:57px;display:flex;align-items:center;justify-content:space-between;padding:0 21px;border-bottom:1px solid var(--line);font-size:12px;font-weight:700;color:#80766a;text-transform:uppercase;letter-spacing:.08em}.paper{margin:22px;padding:28px 20px;min-height:180px;background:#fff;border:1px solid var(--line);border-radius:11px;box-shadow:0 4px 18px rgba(76,57,34,.05);display:grid;place-items:center;align-content:center;text-align:center;color:#8f8578}.paper svg{width:32px;height:32px;color:#ceb8a4;margin-bottom:12px}.paper b{color:#625b50}.paper p{font-size:13px;line-height:1.6}.detail-form{padding:19px;display:grid;gap:15px}.detail-title{display:flex;align-items:flex-start;gap:10px}.check.large{margin-top:8px;width:22px;height:22px;flex-basis:22px}.detail-title textarea{flex:1;border:0;background:transparent;resize:none;outline:none;font-size:19px;line-height:1.4;font-weight:700}.detail-form>label{grid-template-columns:80px 1fr;align-items:center}.detail-form>label input,.detail-form>label select{padding:8px}.field{display:grid;gap:7px}.field-label{display:flex;justify-content:space-between;align-items:center;font-size:12px;font-weight:700;color:#756d61}.tag-picker{display:flex;flex-wrap:wrap;gap:6px}.tag-picker button{border:1px solid var(--line);background:#fff;border-radius:999px;padding:5px 8px;display:flex;align-items:center;gap:5px;font-size:11px}.tag-picker button.chosen{border-color:#c89077;background:#fff1e9}.tag-picker i{width:8px;height:8px;border-radius:50%}.hint{font-size:12px;color:#a49a8d}.markdown .field-label>span:last-child{display:flex;background:#eee7dc;padding:2px;border-radius:6px}.markdown .field-label button{border:0;background:transparent;padding:4px 8px;border-radius:5px;font-size:11px}.markdown .field-label button.active{background:#fff;color:var(--accent)}.markdown textarea{border:1px solid var(--line);background:#fff;border-radius:9px;padding:11px;resize:vertical;outline:none;font:13px/1.6 ui-monospace,SFMono-Regular,Menlo,monospace}.markdown-preview{min-height:160px;padding:10px 12px;background:#fff;border:1px solid var(--line);border-radius:9px;font-size:13px;line-height:1.65;overflow-wrap:anywhere}.markdown-preview h1{font-size:20px}.markdown-preview h2{font-size:16px}.markdown-preview p{margin:8px 0}.markdown-preview code{background:#f2ece2;padding:2px 4px;border-radius:4px}.markdown-preview a{color:var(--accent)}.subtasks{display:grid;gap:5px}.subtask-detail{display:flex;align-items:center;gap:8px;border:0;background:#fff;padding:8px;border-radius:7px;text-align:left}.subtask-detail .check{pointer-events:none}.strike{text-decoration:line-through;color:var(--muted)}.detail-actions{display:flex;justify-content:space-between;align-items:center;padding-top:10px;border-top:1px solid var(--line)}.secondary{border:1px solid var(--line);background:#fff;padding:8px 11px;border-radius:8px;font-weight:650}.danger-text{border:0;background:transparent;color:var(--danger);display:flex;align-items:center;gap:5px;font-size:12px}.danger-text svg{width:14px}
.toast,.error-toast{position:fixed;z-index:50;left:50%;bottom:24px;transform:translateX(-50%);background:#322d28;color:#fff;border-radius:9px;padding:10px 15px;box-shadow:var(--shadow);font-size:13px}.error-toast{background:var(--danger);display:flex;align-items:center;gap:10px}.error-toast button{border:0;background:transparent;color:#fff;padding:0}.toast-enter-active,.toast-leave-active{transition:.2s}.toast-enter-from,.toast-leave-to{opacity:0;transform:translate(-50%,8px)} .toast,.error-toast{position:fixed;z-index:50;left:50%;bottom:24px;transform:translateX(-50%);background:#322d28;color:#fff;border-radius:9px;padding:10px 15px;box-shadow:var(--shadow);font-size:13px}.error-toast{background:var(--danger);display:flex;align-items:center;gap:10px}.error-toast button{border:0;background:transparent;color:#fff;padding:0}.toast-enter-active,.toast-leave-active{transition:.2s}.toast-enter-from,.toast-leave-to{opacity:0;transform:translate(-50%,8px)}
@media(max-width:1050px){.shell{grid-template-columns:220px minmax(400px,1fr) 310px}main{padding-inline:24px}} @media(max-width:1050px){.shell{grid-template-columns:220px minmax(400px,1fr) 310px}main{padding-inline:24px}}
@@ -13,12 +13,13 @@ main{min-width:0;padding:27px 34px 50px;overflow:auto;background:linear-gradient
.habit-row{display:flex;align-items:center;gap:8px;background:#fff;border:1px solid var(--line);border-radius:14px;padding:5px 5px 5px 12px;box-shadow:0 3px 14px rgba(81,61,38,.05);transition:border-color .15s,box-shadow .15s} .habit-row{display:flex;align-items:center;gap:8px;background:#fff;border:1px solid var(--line);border-radius:14px;padding:5px 5px 5px 12px;box-shadow:0 3px 14px rgba(81,61,38,.05);transition:border-color .15s,box-shadow .15s}
.habit-row.done{border-color:var(--accent)} .habit-row.done{border-color:var(--accent)}
.habit-row.swipeable{touch-action:pan-y}
.habit-main{flex:1;min-width:0;display:flex;align-items:center;justify-content:space-between;gap:10px;border:0;background:transparent;padding:13px 4px;text-align:left;border-radius:10px} .habit-main{flex:1;min-width:0;display:flex;align-items:center;justify-content:space-between;gap:10px;border:0;background:transparent;padding:13px 4px;text-align:left;border-radius:10px}
.habit-name{font-size:15px;font-weight:700;color:#3c372f;overflow:hidden;text-overflow:ellipsis;white-space:nowrap} .habit-name{font-size:15px;font-weight:700;color:#3c372f;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
.habit-main>span:first-child{display:grid;gap:3px}.habit-main small{font-size:11px;color:var(--muted)} .habit-main>span:first-child{display:grid;gap:3px}.habit-main small{font-size:11px;color:var(--muted)}
.habit-row.done .habit-name{color:var(--accent)} .habit-row.done .habit-name{color:var(--accent)}
.habit-check-button{width:44px;height:44px;flex:0 0 44px;border-radius:50%;border:1.5px solid #cfc3b3;background:#fff;color:#fff;display:grid;place-items:center;padding:0} .habit-swipe-hint{flex:0 0 auto;border-radius:999px;background:#f3ede3;color:#9a8f80;padding:6px 10px;font-size:11px;font-weight:700}
.habit-check-button.done{background:var(--accent);border-color:var(--accent)}.habit-check-button svg{width:19px;height:19px;stroke-width:2.6} .habit-row.done .habit-swipe-hint{background:var(--accent-soft);color:#b7421e}
.habit-row .icon.ghost{color:#b3a795;margin-left:4px} .habit-row .icon.ghost{color:#b3a795;margin-left:4px}
.habit-row .icon.ghost:hover{color:var(--danger)} .habit-row .icon.ghost:hover{color:var(--danger)}
.numeric-habit>span:first-child{display:grid;gap:3px}.numeric-habit small{font-size:11px;color:var(--muted)} .numeric-habit>span:first-child{display:grid;gap:3px}.numeric-habit small{font-size:11px;color:var(--muted)}