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>
<link rel="stylesheet" crossorigin href="/assets/index-nhCaMkMR.css">
<!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-CPHlHtC0.css">
</head><body><div id="app"></div></body></html>
+20 -4
View File
@@ -6,7 +6,7 @@ import {
Settings, Trash2, X, Repeat2,
} from 'lucide-vue-next'
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 MvpPanel from './MvpPanel.vue'
@@ -43,6 +43,7 @@ const totalTasks = ref(0)
const totalPages = computed(() => Math.max(1, Math.ceil(totalTasks.value / pageSize)))
const expandedFolders = ref(new Set<string>())
const navigationLoaded = ref(false)
const taskSwipeStart = ref<{ id: string; x: number; y: number } | null>(null)
const activeName = computed(() => {
if (activeView.value === 'trash') return '回收站'
@@ -221,6 +222,21 @@ async function patchTask(task: Task, patch: Partial<Task>) {
async function toggle(task: Task) {
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() {
if (!selectedTask.value?.title.trim()) return
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>
<section class="task-list" :class="{loading}">
<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}">
<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>
<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 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>
<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-if="activeView==='trash'" class="icon danger ghost" aria-label="永久删除" @click.stop="purgeTask(node.task)"><X/></button>
</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>
<div v-if="!visibleTasks.length&&!loading" class="empty"><ListTodo/><b>{{query?'没有匹配的任务':'这里还很安静'}}</b><span>{{query?'换个关键词试试':'写下第一件想完成的小事吧'}}</span></div>
</section>
+25 -9
View File
@@ -1,7 +1,7 @@
<script setup lang="ts">
import { onBeforeUnmount, onMounted, ref } from 'vue'
import { Activity, ArchiveRestore, Check, Download, FileJson, LogOut, Plus, RefreshCw, Trash2, Upload } from 'lucide-vue-next'
import { dateKey, isHabitComplete, mergePage, numericHabitInputValue } from './lib/mvp-utils'
import { Activity, ArchiveRestore, Download, FileJson, LogOut, Plus, RefreshCw, Trash2, Upload } from 'lucide-vue-next'
import { dateKey, isHabitComplete, mergePage, numericHabitInputValue, shouldToggleHabitSwipe } from './lib/mvp-utils'
import { csrfHeader } from './lib/csrf'
type View = 'habits' | 'settings'
@@ -20,8 +20,9 @@ const habitTarget = ref(1)
const importFile = ref<File | null>(null)
const importPreview = ref<any>(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 habitSwipeStart = ref<{ id: string; x: number; y: number } | null>(null)
let dayRolloverTimer: ReturnType<typeof setInterval> | undefined
function formatErrorMessage(detail: unknown): string {
@@ -70,6 +71,23 @@ async function toggleHabit(h: Habit, day: string) {
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) {
if (busy.value) return
const next = numericHabitInputValue(numericValues.value[h.id])
@@ -185,14 +203,12 @@ onBeforeUnmount(() => {
</form>
<!-- 习惯列表展示和操作分离只点右侧明确按钮降低误触 -->
<!-- 习惯列表布尔习惯整行右滑打卡数值习惯保留明确输入记录 -->
<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">
<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)">
<Check v-if="isDone(h, todayKey)" />
</button>
<span><span class="habit-name">{{ h.name }}</span><small>{{ isDone(h, todayKey) ? '今天已完成 · 右滑可取消' : '右滑整行打卡' }}</small></span>
<span class="habit-swipe-hint" aria-hidden="true">{{ isDone(h, todayKey) ? '已完成' : '右滑' }}</span>
</div>
<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>
+8 -1
View File
@@ -1,5 +1,5 @@
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', () => {
it('formats a local date as YYYY-MM-DD', () => {
@@ -28,4 +28,11 @@ describe('MVP view utilities', () => {
expect(numericHabitInputValue('')).toBeNull()
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)
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)}}
.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}
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}
.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}}
@@ -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.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-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-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-check-button.done{background:var(--accent);border-color:var(--accent)}.habit-check-button svg{width:19px;height:19px;stroke-width:2.6}
.habit-swipe-hint{flex:0 0 auto;border-radius:999px;background:#f3ede3;color:#9a8f80;padding:6px 10px;font-size:11px;font-weight:700}
.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:hover{color:var(--danger)}
.numeric-habit>span:first-child{display:grid;gap:3px}.numeric-habit small{font-size:11px;color:var(--muted)}