fix: harden calendar subscriptions and add left-swipe reopen
ci / docker (push) Successful in 3m31s

This commit is contained in:
2026-09-06 09:45:00 +08:00
parent b937c8ca72
commit c98223b7d3
12 changed files with 264 additions and 103 deletions
+12 -5
View File
@@ -249,7 +249,11 @@ function moveTaskSwipe(task: Task, event: TouchEvent) {
if (!start || start.id !== task.id || !touch) return
const deltaX = touch.clientX - start.x
const deltaY = touch.clientY - start.y
if (deltaX > 0 && Math.abs(deltaX) > Math.abs(deltaY)) taskSwipeOffsets.value[task.id] = Math.min(deltaX, 92)
if (Math.abs(deltaX) > Math.abs(deltaY)) {
if ((!task.completed && deltaX > 0) || (task.completed && deltaX < 0)) {
taskSwipeOffsets.value[task.id] = Math.max(-92, Math.min(deltaX, 92))
}
}
}
async function finishTaskSwipe(task: Task, event: TouchEvent) {
const start = taskSwipeStart.value
@@ -258,7 +262,10 @@ async function finishTaskSwipe(task: Task, event: TouchEvent) {
taskSwipeOffsets.value[task.id] = 0
if (!start || start.id !== task.id || activeView.value === 'trash' || loading.value) return
const touch = event.changedTouches[0]
if (touch && shouldToggleRowSwipe(touch.clientX - start.x, touch.clientY - start.y)) {
const deltaX = touch ? touch.clientX - start.x : 0
const deltaY = touch ? touch.clientY - start.y : 0
const expectedDirection = task.completed ? deltaX < 0 : deltaX > 0
if (touch && expectedDirection && shouldToggleRowSwipe(Math.abs(deltaX), deltaY)) {
suppressTaskClickId = task.id
window.setTimeout(() => { if (suppressTaskClickId === task.id) suppressTaskClickId = '' }, 400)
await toggle(task)
@@ -437,8 +444,8 @@ 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 swipeable" :class="{done:node.task.completed,selected:selectedTask?.id===node.task.id,ready:(taskSwipeOffsets[node.task.id] ?? 0) >= 64}" :style="{ '--swipe-x': `${taskSwipeOffsets[node.task.id] ?? 0}px` }" @touchstart.passive="startTaskSwipe(node.task, $event)" @touchmove.passive="moveTaskSwipe(node.task, $event)" @touchend="finishTaskSwipe(node.task, $event)" @touchcancel="cancelTaskSwipe(node.task)">
<span class="swipe-bg"> 完成</span>
<article class="task-row swipeable" :class="{done:node.task.completed,selected:selectedTask?.id===node.task.id,ready:Math.abs(taskSwipeOffsets[node.task.id] ?? 0) >= 64}" :style="{ '--swipe-x': `${taskSwipeOffsets[node.task.id] ?? 0}px`, '--swipe-width': `${Math.abs(taskSwipeOffsets[node.task.id] ?? 0)}px` }" @touchstart.passive="startTaskSwipe(node.task, $event)" @touchmove.passive="moveTaskSwipe(node.task, $event)" @touchend="finishTaskSwipe(node.task, $event)" @touchcancel="cancelTaskSwipe(node.task)">
<span class="swipe-bg" :class="{ cancel: node.task.completed }">{{ node.task.completed ? '↩ 未完成' : '✓ 完成' }}</span>
<div class="task-main" role="button" tabindex="0" @click="activeView==='trash'?undefined:selectTaskUnlessSwiped(node.task)" @keydown.enter="activeView==='trash'?undefined:selectTaskUnlessSwiped(node.task)" @keydown.space.prevent="activeView==='trash'?undefined:selectTaskUnlessSwiped(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></div>
<button v-if="activeView!=='trash'" class="task-swipe-hint" :aria-label="node.task.completed ? `重新打开${node.task.title}` : `完成${node.task.title}`" :aria-pressed="node.task.completed" @click.stop="toggle(node.task)">{{ node.task.completed ? '已完成' : '完成' }}</button>
<span v-if="node.task.priority" class="priority" :class="`p${node.task.priority}`">{{['','','',''][node.task.priority]}}</span>
@@ -446,7 +453,7 @@ onMounted(bootstrap)
<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 swipeable" :class="{done:subtask.completed,ready:(taskSwipeOffsets[subtask.id] ?? 0) >= 64}" :style="{ '--swipe-x': `${taskSwipeOffsets[subtask.id] ?? 0}px` }" @touchstart.passive="startTaskSwipe(subtask, $event)" @touchmove.passive="moveTaskSwipe(subtask, $event)" @touchend="finishTaskSwipe(subtask, $event)" @touchcancel="cancelTaskSwipe(subtask)"><span class="swipe-bg">✓ 完成</span><GripVertical/><div class="task-main" role="button" tabindex="0" @click="selectTaskUnlessSwiped(subtask)" @keydown.enter="selectTaskUnlessSwiped(subtask)" @keydown.space.prevent="selectTaskUnlessSwiped(subtask)"><strong>{{subtask.title}}</strong></div><button class="task-swipe-hint" :aria-label="subtask.completed ? `重新打开${subtask.title}` : `完成${subtask.title}`" :aria-pressed="subtask.completed" @click.stop="toggle(subtask)">{{ subtask.completed ? '已完成' : '完成' }}</button></article>
<article v-for="subtask in node.subtasks" :key="subtask.id" class="task-row subtask swipeable" :class="{done:subtask.completed,ready:Math.abs(taskSwipeOffsets[subtask.id] ?? 0) >= 64}" :style="{ '--swipe-x': `${taskSwipeOffsets[subtask.id] ?? 0}px`, '--swipe-width': `${Math.abs(taskSwipeOffsets[subtask.id] ?? 0)}px` }" @touchstart.passive="startTaskSwipe(subtask, $event)" @touchmove.passive="moveTaskSwipe(subtask, $event)" @touchend="finishTaskSwipe(subtask, $event)" @touchcancel="cancelTaskSwipe(subtask)"><span class="swipe-bg" :class="{ cancel: subtask.completed }">{{ subtask.completed ? '↩ 未完成' : '✓ 完成' }}</span><GripVertical/><div class="task-main" role="button" tabindex="0" @click="selectTaskUnlessSwiped(subtask)" @keydown.enter="selectTaskUnlessSwiped(subtask)" @keydown.space.prevent="selectTaskUnlessSwiped(subtask)"><strong>{{subtask.title}}</strong></div><button class="task-swipe-hint" :aria-label="subtask.completed ? `重新打开${subtask.title}` : `完成${subtask.title}`" :aria-pressed="subtask.completed" @click.stop="toggle(subtask)">{{ subtask.completed ? '已完成' : '完成' }}</button></article>
</template>
<div v-if="!visibleTasks.length&&!loading" class="empty"><ListTodo/><b>{{query?'没有匹配的任务':'这里还很安静'}}</b><span>{{query?'换个关键词试试':'写下第一件想完成的小事吧'}}</span></div>
</section>
+14 -6
View File
@@ -95,7 +95,12 @@ function moveHabitSwipe(h: Habit, event: TouchEvent) {
if (!start || start.id !== h.id || !touch) return
const deltaX = touch.clientX - start.x
const deltaY = touch.clientY - start.y
if (deltaX > 0 && Math.abs(deltaX) > Math.abs(deltaY)) habitSwipeOffsets.value[h.id] = Math.min(deltaX, 92)
const done = isDone(h, todayKey.value)
if (Math.abs(deltaX) > Math.abs(deltaY)) {
if ((!done && deltaX > 0) || (done && deltaX < 0)) {
habitSwipeOffsets.value[h.id] = Math.max(-92, Math.min(deltaX, 92))
}
}
}
async function finishHabitSwipe(h: Habit, event: TouchEvent) {
const start = habitSwipeStart.value
@@ -103,7 +108,10 @@ async function finishHabitSwipe(h: Habit, event: TouchEvent) {
habitSwipeOffsets.value[h.id] = 0
if (!start || start.id !== h.id || h.kind === 'numeric' || busy.value) return
const touch = event.changedTouches[0]
if (touch && shouldToggleRowSwipe(touch.clientX - start.x, touch.clientY - start.y)) {
const deltaX = touch ? touch.clientX - start.x : 0
const deltaY = touch ? touch.clientY - start.y : 0
const expectedDirection = isDone(h, todayKey.value) ? deltaX < 0 : deltaX > 0
if (touch && expectedDirection && shouldToggleRowSwipe(Math.abs(deltaX), deltaY)) {
await toggleHabit(h, todayKey.value)
}
}
@@ -283,11 +291,11 @@ onBeforeUnmount(() => {
<!-- 习惯列表布尔习惯整行右滑打卡数值习惯保留明确输入记录 -->
<div class="habit-list">
<article v-for="h in habits" :key="h.id" class="habit-row" :class="{ done: isDone(h, todayKey), swipeable: h.kind !== 'numeric', ready: (habitSwipeOffsets[h.id] ?? 0) >= 64 }" :style="{ '--swipe-x': `${habitSwipeOffsets[h.id] ?? 0}px` }" @touchstart.passive="startHabitSwipe(h, $event)" @touchmove.passive="moveHabitSwipe(h, $event)" @touchend="finishHabitSwipe(h, $event)" @touchcancel="cancelHabitSwipe(h)">
<span v-if="h.kind !== 'numeric'" class="swipe-bg"> 打卡</span>
<div v-if="view === 'habits'" class="habit-list">
<article v-for="h in habits" :key="h.id" class="habit-row" :class="{ done: isDone(h, todayKey), swipeable: h.kind !== 'numeric', ready: Math.abs(habitSwipeOffsets[h.id] ?? 0) >= 64 }" :style="{ '--swipe-x': `${habitSwipeOffsets[h.id] ?? 0}px`, '--swipe-width': `${Math.abs(habitSwipeOffsets[h.id] ?? 0)}px` }" @touchstart.passive="startHabitSwipe(h, $event)" @touchmove.passive="moveHabitSwipe(h, $event)" @touchend="finishHabitSwipe(h, $event)" @touchcancel="cancelHabitSwipe(h)">
<span v-if="h.kind !== 'numeric'" class="swipe-bg" :class="{ cancel: isDone(h, todayKey) }">{{ isDone(h, todayKey) ? ' 未完成' : ' 打卡' }}</span>
<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-swipe-hint" :aria-label="isDone(h, todayKey) ? `取消${h.name}今天的打卡` : `完成${h.name}今天的打卡`" :aria-pressed="isDone(h, todayKey)" @click.stop="toggleHabitFromButton(h)">{{ isDone(h, todayKey) ? '已完成' : '打卡' }}</button>
</div>
<div v-else class="habit-main numeric-habit">
+1 -1
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{--swipe-x:0px;min-height:53px;display:flex;align-items:center;gap:10px;border-bottom:1px solid var(--line);padding:4px 8px;transition:background .15s,transform .18s ease;position:relative;overflow:hidden;isolation:isolate}.task-row.swipeable{touch-action:pan-y;transform:translateX(var(--swipe-x))}.task-row.swipeable:not([style*="0px"]){transition:none}.task-row:hover,.task-row.selected{background:rgba(250,240,229,.75)}.task-row:hover{transform:translateX(calc(var(--swipe-x) + 2px))}.swipe-bg{position:absolute;z-index:-1;inset:0 auto 0 calc(-1 * var(--swipe-x));width:var(--swipe-x);min-width:0;display:flex;align-items:center;padding-left:14px;background:#e9f7ee;color:#24814f;font-size:12px;font-weight:800;white-space:nowrap;overflow:hidden}.task-row.ready .swipe-bg,.habit-row.ready .swipe-bg{background:#dff3e7;color:#187142}.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{min-height:44px;flex:0 0 auto;border:0;border-radius:999px;background:#f3ede3;color:#9a8f80;padding:8px 11px;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}
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{--swipe-x:0px;min-height:53px;display:flex;align-items:center;gap:10px;border-bottom:1px solid var(--line);padding:4px 8px;transition:background .15s,transform .18s ease;position:relative;overflow:hidden;isolation:isolate}.task-row.swipeable{touch-action:pan-y;transform:translateX(var(--swipe-x))}.task-row.swipeable:not([style*="0px"]){transition:none}.task-row:hover,.task-row.selected{background:rgba(250,240,229,.75)}.task-row:hover{transform:translateX(calc(var(--swipe-x) + 2px))}.swipe-bg{position:absolute;z-index:-1;inset:0 auto 0 calc(-1 * var(--swipe-x));width:var(--swipe-width);min-width:0;display:flex;align-items:center;padding-left:14px;background:#e9f7ee;color:#24814f;font-size:12px;font-weight:800;white-space:nowrap;overflow:hidden}.swipe-bg.cancel{left:auto;right:var(--swipe-x);justify-content:flex-end;padding:0 14px 0 0;background:#fff2e8;color:#c65a23}.task-row.ready .swipe-bg,.habit-row.ready .swipe-bg{background:#dff3e7;color:#187142}.task-row.ready .swipe-bg.cancel,.habit-row.ready .swipe-bg.cancel{background:#ffe4d3;color:#b94716}.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{min-height:44px;flex:0 0 auto;border:0;border-radius:999px;background:#f3ede3;color:#9a8f80;padding:8px 11px;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}}