refactor: simplify dodo core experience
ci / docker (push) Successful in 3m54s

This commit is contained in:
2026-09-06 14:41:08 +08:00
parent fcaabce99f
commit d595af4f52
29 changed files with 187 additions and 955 deletions
+31 -95
View File
@@ -3,20 +3,17 @@ import { computed, nextTick, onMounted, ref, watch } from 'vue'
import {
ArchiveRestore, CalendarDays, Check, ChevronDown, ChevronRight, CirclePlus, Folder,
GripVertical, Inbox, ListChecks, ListTodo, Menu, Pencil, Plus, Search,
Settings, Trash2, X, Repeat2,
Settings, Trash2, X, Repeat2, Ellipsis,
} from 'lucide-vue-next'
import { filterTasks, fromDateTimeLocal, groupTaskTree, renderMarkdown, toDateTimeLocal } from './lib/task-utils'
import { dateKey, defaultView, isTaskView, shouldToggleRowSwipe } from './lib/mvp-utils'
import { defaultView, isTaskView, quickTaskFields, shouldToggleRowSwipe } from './lib/mvp-utils'
import { csrfHeader } from './lib/csrf'
import MvpPanel from './MvpPanel.vue'
type FolderItem = { id: string; name: string }
type TaskList = { id: string; folder_id: string | null; name: string; is_inbox: boolean }
type CalendarSubscription = { id: string; name: string; url: string; color?: string }
type Tag = { id: string; name: string; color: string }
type Task = { id: string; list_id: string; parent_id: string | null; title: string; description: string; priority: number; completed: boolean; version: number; due_at: string | null; recurrence_rule?: string | null; recurrence_end_at?: string | null; tags?: Tag[]; subtasks?: Task[] }
type CalendarEvent = { id: string; title: string; starts_at: string; ends_at?: string | null; all_day: boolean; source_name: string; color?: string }
type View = 'tasks' | 'today' | 'upcoming' | 'trash' | 'habits' | 'calendar' | 'settings'
type Task = { id: string; list_id: string; parent_id: string | null; title: string; description: string; priority: number; completed: boolean; version: number; due_at: string | null; subtasks?: Task[] }
type View = 'tasks' | 'today' | 'upcoming' | 'trash' | 'habits' | 'settings'
const initialized = ref<boolean | null>(null)
const authReady = ref(false)
@@ -26,11 +23,6 @@ const password = ref('')
const folders = ref<FolderItem[]>([])
const lists = ref<TaskList[]>([])
const archivedLists = ref<TaskList[]>([])
const calendarSubscriptions = ref<CalendarSubscription[]>([])
const calendarEvents = ref<CalendarEvent[]>([])
const calendarName = ref('合并日历')
const calendarUrl = ref('https://cal.bboy.app/all.ics')
const tags = ref<Tag[]>([])
const tasks = ref<Task[]>([])
const trash = ref<Task[]>([])
const activeList = ref('')
@@ -43,6 +35,8 @@ const notice = ref('')
const loading = ref(false)
const mobileSidebar = ref(false)
const mobileDetail = ref(false)
const mobileMore = ref(false)
const moreSettingsOpen = ref(false)
const markdownPreview = ref(false)
const showCompleted = ref(true)
const page = ref(1)
@@ -83,7 +77,6 @@ function confirmModal() {
const activeName = computed(() => {
if (activeView.value === 'trash') return '回收站'
if (activeView.value === 'today') return '今天'
if (activeView.value === 'calendar') return '日历'
if (activeView.value === 'upcoming') return '最近 7 天'
if (activeView.value === 'habits') return '习惯'
if (activeView.value === 'settings') return '设置与数据'
@@ -94,7 +87,7 @@ const visibleTasks = computed(() => {
const now = new Date()
const end = new Date(now); end.setDate(end.getDate() + 7)
let result = sourceTasks.value
if (['habits','settings','calendar'].includes(activeView.value)) return []
if (['habits','settings'].includes(activeView.value)) return []
if (activeView.value === 'today') result = result.filter((task) => task.due_at && new Date(task.due_at).toDateString() === now.toDateString())
if (activeView.value === 'upcoming') result = result.filter((task) => task.due_at && new Date(task.due_at) >= now && new Date(task.due_at) <= end)
return query.value.trim() ? filterTasks(result, query.value) : result
@@ -149,7 +142,6 @@ async function bootstrap() {
authenticated.value = true
folders.value = data.folders ?? []
lists.value = data.lists ?? []
tags.value = data.tags ?? []
navigationLoaded.value = true
activeList.value = data.inbox_id || lists.value.find((item: TaskList) => item.is_inbox)?.id || lists.value[0]?.id || ''
expandedFolders.value = new Set(folders.value.map((folder) => folder.id))
@@ -171,7 +163,6 @@ async function submitAuth() {
const data = await api('/bootstrap')
folders.value = data.folders ?? []
lists.value = data.lists ?? []
tags.value = data.tags ?? []
navigationLoaded.value = true
activeList.value = data.inbox_id || lists.value.find((item: TaskList) => item.is_inbox)?.id || lists.value[0]?.id || ''
expandedFolders.value = new Set(folders.value.map((folder) => folder.id))
@@ -208,10 +199,8 @@ async function loadTasksPage() {
}
async function loadNavigation(force = false) {
if (!force && navigationLoaded.value) return
const [folderData, listData, tagData] = await Promise.all([
api('/folders'), api('/lists'), api('/tags').catch(() => []),
])
folders.value = folderData; lists.value = listData; tags.value = tagData
const [folderData, listData] = await Promise.all([api('/folders'), api('/lists')])
folders.value = folderData; lists.value = listData
navigationLoaded.value = true
if (!lists.value.some((item) => item.id === activeList.value)) {
activeList.value = lists.value.find((item) => item.is_inbox)?.id || lists.value[0]?.id || ''
@@ -244,22 +233,23 @@ async function switchView(view: View, listId?: string) {
activeView.value = view
if (listId) activeList.value = listId
page.value = 1
selectedTask.value = null; mobileSidebar.value = false; mobileDetail.value = false
selectedTask.value = null; mobileSidebar.value = false; mobileDetail.value = false; mobileMore.value = false
if (view === 'trash') await loadTrash()
else if (view === 'calendar') await loadCalendarTab()
else if (view === 'today') await loadTodayView()
else if (!isTaskView(view)) tasks.value = []
else await loadAll()
}
async function addTask() {
if (!title.value.trim() || !activeList.value) return
const inboxId = lists.value.find((item) => item.is_inbox)?.id || activeList.value
if (!title.value.trim() || !inboxId) return
try {
const task = await api('/tasks', { method: 'POST', body: JSON.stringify({ title: title.value.trim(), list_id: activeList.value }) })
tasks.value.push(task); title.value = ''; selectTask(task); toast('任务已添加')
const fields = quickTaskFields(activeView.value, activeList.value, inboxId)
const task = await api('/tasks', { method: 'POST', body: JSON.stringify({ title: title.value.trim(), ...fields }) })
tasks.value.push(task); title.value = ''; selectTask(task); mobileDetail.value = true; toast('任务已添加')
} catch (reason) { fail(reason) }
}
async function loadTodayView() {
await Promise.all([loadAll(), loadTodayEvents()])
await loadAll()
}
async function patchTask(task: Task, patch: Partial<Task>) {
const updated = await api(`/tasks/${task.id}`, { method: 'PATCH', body: JSON.stringify({ ...patch, version: task.version }) })
@@ -327,7 +317,7 @@ async function saveTask() {
if (!selectedTask.value?.title.trim()) return
try {
const task = selectedTask.value
await patchTask(task, { title: task.title.trim(), description: task.description, priority: Number(task.priority), due_at: fromDateTimeLocal(toDateTimeLocal(task.due_at)), list_id: task.list_id, recurrence_rule: task.recurrence_rule || null, recurrence_end_at: task.recurrence_end_at || null, tag_ids: (task.tags ?? []).map((tag) => tag.id) } as Partial<Task>)
await patchTask(task, { title: task.title.trim(), description: task.description, priority: Number(task.priority), due_at: fromDateTimeLocal(toDateTimeLocal(task.due_at)), list_id: task.list_id } as Partial<Task>)
toast('已保存')
} catch (reason) { fail(reason) }
}
@@ -349,8 +339,8 @@ async function addSubtask() {
try { const child = await api('/tasks', { method: 'POST', body: JSON.stringify({ title: subtaskTitle, list_id: selectedTask.value.list_id, parent_id: selectedTask.value.id }) }); if (selectedTask.value) selectedTask.value.subtasks = [...(selectedTask.value.subtasks ?? []), child]; tasks.value.push(child); toast('子任务已添加') } catch (reason) { fail(reason) }
}
function selectTask(task: Task) {
selectedTask.value = { ...task, tags: task.tags ? [...task.tags] : [] }
markdownPreview.value = false; mobileDetail.value = true
selectedTask.value = { ...task, subtasks: task.subtasks ? [...task.subtasks] : [] }
markdownPreview.value = false; moreSettingsOpen.value = false; mobileDetail.value = true
}
async function createFolder() {
const name = (await askText('新建文件夹', '文件夹名称', '', '创建'))?.trim(); if (!name) return
@@ -364,11 +354,6 @@ async function renameEntity(kind: 'folders' | 'lists', item: FolderItem | TaskLi
const name = (await askText('重命名', kind === 'lists' ? '清单名称' : '文件夹名称', item.name, '保存'))?.trim(); if (!name || name === item.name) return
try { const updated = await api(`/${kind}/${item.id}`, { method: 'PATCH', body: JSON.stringify({ name }) }); Object.assign(item, updated); toast('已重命名') } catch (reason) { fail(reason) }
}
async function createTag() {
const name = (await askText('新建标签', '标签名称', '', '创建'))?.trim(); if (!name) return
const color = (await askText('标签颜色', '颜色(十六进制)', '#F15A29', '创建'))?.trim() || '#F15A29'
try { tags.value.push(await api('/tags', { method: 'POST', body: JSON.stringify({ name, color }) })); toast('标签已创建') } catch (reason) { fail(reason) }
}
async function deleteEntity(kind: 'folders' | 'lists', item: FolderItem | TaskList) {
if (kind === 'lists') {
const answer = await askText(`归档清单「${item.name}」?`, '', '', '归档')
@@ -386,45 +371,8 @@ async function loadArchivedLists() {
async function restoreList(item: TaskList) {
try { await api(`/lists/${item.id}/restore`, { method: 'POST' }); await loadArchivedLists(); await refreshAll(); toast('清单已恢复') } catch (reason) { fail(reason) }
}
async function loadCalendarTab() {
try {
calendarSubscriptions.value = await api('/calendar-subscriptions')
if (calendarSubscriptions.value.length) {
calendarEvents.value = await api(`/calendar-subscriptions/today?day=${dateKey(new Date())}`)
} else {
calendarEvents.value = []
}
} catch { calendarSubscriptions.value = []; calendarEvents.value = [] }
}
async function addCalendarSubscription() {
if (!calendarUrl.value.trim()) return
try {
await api('/calendar-subscriptions', { method: 'POST', body: JSON.stringify({ name: calendarName.value.trim() || calendarUrl.value, url: calendarUrl.value.trim(), color: '#f15a29' }) })
await loadCalendarTab(); toast('日历已订阅')
} catch (reason) { fail(reason) }
}
async function removeCalendarSubscription(id: string) {
if (!window.confirm('取消这个日历订阅?')) return
try { await api(`/calendar-subscriptions/${id}`, { method: 'DELETE' }); await loadCalendarTab(); toast('已取消订阅') } catch (reason) { fail(reason) }
}
function taskHasTag(tag: Tag) { return !!selectedTask.value?.tags?.some((item) => item.id === tag.id) }
function toggleTag(tag: Tag) {
if (!selectedTask.value) return
selectedTask.value.tags = taskHasTag(tag) ? (selectedTask.value.tags ?? []).filter((item) => item.id !== tag.id) : [...(selectedTask.value.tags ?? []), tag]
}
function toggleFolder(id: string) { const next = new Set(expandedFolders.value); next.has(id) ? next.delete(id) : next.add(id); expandedFolders.value = next }
function formatDue(value: string | null) { return value ? new Intl.DateTimeFormat('zh-CN', { month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit' }).format(new Date(value)) : '' }
function formatEventTime(item: CalendarEvent) {
if (item.all_day) return '全天'
const date = new Date(item.starts_at)
return `${date.getHours().toString().padStart(2, '0')}:${date.getMinutes().toString().padStart(2, '0')}`
}
async function loadTodayEvents() {
try {
calendarSubscriptions.value = await api('/calendar-subscriptions')
calendarEvents.value = calendarSubscriptions.value.length ? await api(`/calendar-subscriptions/today?day=${dateKey(new Date())}`) : []
} catch { calendarEvents.value = [] }
}
function focusQuick() { nextTick(() => document.querySelector<HTMLInputElement>('.quick-input')?.focus()) }
function previousPage() {
if (page.value <= 1 || loading.value) return
@@ -459,7 +407,6 @@ onMounted(bootstrap)
<button :class="{ active: activeView==='tasks' && lists.find(l=>l.id===activeList)?.is_inbox }" @click="switchView('tasks', lists.find(l=>l.is_inbox)?.id)"><Inbox />收集箱</button>
<button :class="{ active: activeView==='today' }" @click="switchView('today')"><ListTodo />今天</button>
<button :class="{ active: activeView==='upcoming' }" @click="switchView('upcoming')"><CalendarDays />最近 7 </button>
<button :class="{ active: activeView==='calendar' }" @click="switchView('calendar')"><CalendarDays />日历</button>
<button :class="{ active: activeView==='habits' }" @click="switchView('habits')"><Repeat2 />习惯</button>
<button :class="{ active: activeView==='trash' }" @click="switchView('trash')"><Trash2 />回收站</button>
</nav>
@@ -481,18 +428,11 @@ onMounted(bootstrap)
<main>
<header class="topbar">
<button class="icon mobile-only" aria-label="打开菜单" @click="mobileSidebar=true"><Menu /></button>
<div><p>今天也慢慢来</p><h1>{{ activeName }}</h1></div>
<div><h1>{{ activeName }}</h1></div>
<label v-if="['tasks','today','upcoming','trash'].includes(activeView)" class="search"><Search/><input v-model="query" placeholder="搜索任务…" aria-label="搜索任务"><kbd> K</kbd></label>
</header>
<template v-if="['habits','settings','calendar'].includes(activeView)">
<MvpPanel v-if="['habits','settings'].includes(activeView)" :key="activeView" :view="activeView as 'habits'|'settings'" @changed="refreshAll" @notice="toast" />
<template v-else-if="activeView === 'calendar'">
<header class="view-intro"><div><small>订阅外部日历每天自动同步</small><h2>日历</h2></div><button class="soft-button" @click="loadCalendarTab"><RefreshCw />刷新</button></header>
<div class="settings-grid">
<article class="tool-card wide"><Upload /><h3>添加订阅</h3><p>粘贴 ICS / webcal 链接例如 cal.bboy.app 的合并日历</p><div class="sub-add"><input v-model="calendarName" placeholder="名称(可选)" aria-label="订阅名称"><input v-model="calendarUrl" placeholder="https://…/all.ics" aria-label="订阅地址"><button class="primary-small" @click="addCalendarSubscription">订阅</button></div><div v-if="calendarSubscriptions.length"><div v-for="sub in calendarSubscriptions" :key="sub.id" class="session-row"><span><b>{{sub.name}}</b><small>{{sub.url}}</small></span><button class="danger-text" @click="removeCalendarSubscription(sub.id)">取消订阅</button></div></div><p v-else class="hint">还没有日历订阅</p></article>
<article v-if="calendarSubscriptions.length" class="tool-card wide"><CalendarDays /><h3>今日事件</h3><div v-if="calendarEvents.length" class="calendar-events"><div v-for="ev in calendarEvents" :key="ev.id + ev.starts_at" class="calendar-event"><span class="ev-time">{{ formatEventTime(ev) }}</span><b>{{ ev.title }}</b><small>{{ ev.source_name }}</small></div></div><p v-else class="hint">今天没有日历事件</p></article>
</div>
</template>
<template v-if="['habits','settings'].includes(activeView)">
<MvpPanel :key="activeView" :view="activeView as 'habits'|'settings'" @changed="refreshAll" @notice="toast" />
</template>
<template v-else>
<template v-if="activeView==='today'">
@@ -505,21 +445,17 @@ onMounted(bootstrap)
<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: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>
<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></span></div>
<button v-if="activeView!=='trash'" class="sr-only" :aria-label="node.task.completed ? `重新打开${node.task.title}` : `完成${node.task.title}`" :aria-pressed="node.task.completed" @click.stop="toggle(node.task)">切换任务状态</button>
<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 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>
<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="sr-only" :aria-label="subtask.completed ? `重新打开${subtask.title}` : `完成${subtask.title}`" :aria-pressed="subtask.completed" @click.stop="toggle(subtask)">切换任务状态</button></article>
</template>
<div v-if="!visibleTasks.length&&!loading" class="empty"><ListTodo/><b>{{query?'没有匹配的任务':'这里还很安静'}}</b><span>{{query?'换个关键词试试':'写下第一件想完成的小事吧'}}</span></div>
</section>
<div v-if="activeView==='today' && calendarEvents.length">
<h3 class="section-heading"><CalendarDays/>今日日历事件</h3>
<div class="calendar-events"><div v-for="ev in calendarEvents" :key="ev.id + ev.starts_at" class="calendar-event"><span class="ev-time">{{ formatEventTime(ev) }}</span><b>{{ ev.title }}</b><small>{{ ev.source_name }}</small></div></div>
</div>
<div v-if="activeView==='today'">
<h3 class="section-heading"><Repeat2/>今日习惯</h3>
<MvpPanel view="today-habits" @changed="refreshAll" @notice="toast" />
@@ -533,18 +469,18 @@ onMounted(bootstrap)
<div class="detail-title"><button class="check large" :class="`p${selectedTask.priority}`" @click="toggle(selectedTask)"><Check v-if="selectedTask.completed"/></button><textarea v-model="selectedTask.title" rows="2" aria-label="任务标题" @blur="saveTask"/></div>
<label>清单<select v-model="selectedTask.list_id" @change="saveTask"><option v-for="list in lists" :key="list.id" :value="list.id">{{list.name}}</option></select></label>
<label>截止时间<input :value="toDateTimeLocal(selectedTask.due_at)" type="datetime-local" @change="selectedTask!.due_at=($event.target as HTMLInputElement).value;saveTask()"></label>
<label>优先级<select v-model.number="selectedTask.priority" @change="saveTask"><option :value="0"></option><option :value="1"></option><option :value="2"></option><option :value="3"></option></select></label>
<label>重复<select v-model="selectedTask.recurrence_rule" @change="saveTask"><option value="">不重复</option><option value="FREQ=DAILY">每天</option><option value="FREQ=WEEKLY">每周</option><option value="FREQ=MONTHLY">每月</option></select></label>
<label v-if="selectedTask.recurrence_rule">重复截止<input v-model="selectedTask.recurrence_end_at" type="date" @change="saveTask"></label>
<div class="field"><div class="field-label"><span>标签</span><button class="link" @click="createTag"><Plus/>新建</button></div><div class="tag-picker"><button v-for="tag in tags" :key="tag.id" :class="{chosen:taskHasTag(tag)}" @click="toggleTag(tag);saveTask()"><i :style="{background:tag.color}"/>{{tag.name}}</button><span v-if="!tags.length" class="hint">还没有标签</span></div></div>
<div class="field markdown"><div class="field-label"><span>备注</span><span><button :class="{active:!markdownPreview}" @click="markdownPreview=false">编辑</button><button :class="{active:markdownPreview}" @click="markdownPreview=true">预览</button></span></div><div v-if="markdownPreview" class="markdown-preview" v-html="renderMarkdown(selectedTask.description)"/><textarea v-else v-model="selectedTask.description" rows="9" placeholder="支持 Markdown…" @blur="saveTask"/></div>
<div class="subtasks"><div class="field-label"><span>子任务</span><button class="link" @click="addSubtask"><Plus/>添加</button></div><button v-for="subtask in selectedTaskSubtasks" :key="subtask.id" class="subtask-detail" @click="toggle(subtask)"><span class="check"><Check v-if="subtask.completed"/></span><span :class="{strike:subtask.completed}">{{subtask.title}}</span></button><span v-if="!selectedTaskSubtasks.length" class="hint">把这件事拆成更小的步骤</span></div>
<details class="more-settings" :open="moreSettingsOpen" @toggle="moreSettingsOpen=($event.target as HTMLDetailsElement).open"><summary>更多设置</summary><div class="more-settings-body">
<label>优先级<select v-model.number="selectedTask.priority" @change="saveTask"><option :value="0"></option><option :value="1"></option><option :value="2"></option><option :value="3"></option></select></label>
</div></details>
<div class="detail-actions"><button class="secondary" @click="saveTask">保存更改</button><button class="danger-text" @click="removeTask(selectedTask)"><Trash2/>移到回收站</button></div>
</div>
<div v-else class="paper"><ListChecks/><b>选中一个任务</b><p>日期优先级标签子任务和 Markdown 备注会出现在这里</p></div>
<div v-else class="paper"><ListChecks/><b>选中一个任务</b><p>日期优先级子任务和 Markdown 备注会出现在这里</p></div>
</aside>
<nav class="bottom"><button :class="{active:activeView==='today'}" @click="switchView('today')"><ListTodo/><span>今天</span></button><button :class="{active:activeView==='tasks'}" @click="switchView('tasks',activeList)"><Inbox/><span>任务</span></button><button :class="{active:activeView==='calendar'}" @click="switchView('calendar')"><CalendarDays/><span>日历</span></button><button :class="{active:activeView==='habits'}" @click="switchView('habits')"><Repeat2/><span>习惯</span></button><button :class="{active:activeView==='settings'}" @click="switchView('settings')"><Settings/><span>设置</span></button></nav>
<div v-if="mobileMore" class="more-mask" @click.self="mobileMore=false"><section id="mobile-more-menu" class="more-sheet" role="dialog" aria-modal="true" aria-label="更多导航"><div class="more-sheet-head"><b>更多</b><button class="icon" aria-label="关闭更多菜单" @click="mobileMore=false"><X/></button></div><button @click="switchView('upcoming')"><CalendarDays/>最近 7 天</button><button @click="switchView('trash')"><Trash2/>回收站</button><button @click="switchView('settings')"><Settings/>设置与数据</button><button @click="mobileMore=false;mobileSidebar=true"><Menu/>全部清单</button></section></div>
<nav class="bottom" aria-label="主要导航"><button :class="{active:activeView==='today'}" @click="switchView('today')"><ListTodo/><span>今天</span></button><button :class="{active:activeView==='tasks'}" @click="switchView('tasks',lists.find(l=>l.is_inbox)?.id)"><Inbox/><span>任务</span></button><button :class="{active:activeView==='habits'}" @click="switchView('habits')"><Repeat2/><span>习惯</span></button><button :class="{active:mobileMore||['upcoming','trash','settings'].includes(activeView)}" aria-haspopup="dialog" aria-controls="mobile-more-menu" :aria-expanded="mobileMore" @click="mobileMore=!mobileMore"><Ellipsis/><span>更多</span></button></nav>
<button v-if="['tasks','today','upcoming'].includes(activeView)" class="fab" aria-label="添加任务" @click="focusQuick"><CirclePlus/></button>
<Transition name="toast"><div v-if="notice" class="toast" role="status">{{notice}}</div></Transition>
<div v-if="error" class="error-toast" role="alert">{{error}}<button @click="error=''"><X/></button></div>
+6 -72
View File
@@ -6,18 +6,12 @@ import { csrfHeader } from './lib/csrf'
type View = 'habits' | 'today-habits' | 'settings'
type Habit = { id: string; name: string; kind?: string; target?: number; max_value?: number | null; unit?: string; cells?: Array<{ day: string; scheduled?:boolean; paused?:boolean; value: number | boolean }>; stats?: Record<string, number> }
type CalendarSubscription = { id: string; name: string; url: string; color: string; enabled?: boolean }
type CalendarEvent = { id: string; title: string; starts_at: string; ends_at?: string | null; all_day: boolean; source_name: string; color?: string }
type Session = { id: string; created_at?: string; last_seen_at?: string; current?: boolean; user_agent?: string }
const props = defineProps<{ view: View }>()
const emit = defineEmits<{ changed: []; notice: [message: string] }>()
const habits = ref<Habit[]>([])
const sessions = ref<Session[]>([])
const audit = ref<any[]>([])
const calendarSubscriptions = ref<CalendarSubscription[]>([])
const todayEvents = ref<CalendarEvent[]>([])
const calendarName = ref('合并日历')
const calendarUrl = ref('https://cal.bboy.app/all.ics')
const busy = ref(false)
const error = ref('')
const habitName = ref('')
@@ -68,16 +62,6 @@ async function safe(work: () => Promise<void>) {
function logFor(h: Habit, day: string) { return (h.cells ?? []).find((c) => c.day === day) }
function isDone(h: Habit, day: string) { return isHabitComplete(h.kind, logFor(h, day)?.value, h.target ?? 1) }
async function toggleHabit(h: Habit, day: string) {
if (busy.value) return
const current = logFor(h, day)
const next = current?.value ? 0 : 1
await safe(async () => {
await request(`/habits/${h.id}/logs/${day}`, { method: 'PUT', body: JSON.stringify({ value: next }) })
await loadHabits()
emit('notice', next ? '打卡成功 🎉' : '已取消打卡')
})
}
function isInteractiveTarget(target: EventTarget | null) {
return target instanceof Element && Boolean(target.closest('button,input,select,textarea,a,label'))
}
@@ -105,13 +89,7 @@ function moveHabitSwipe(h: Habit, event: TouchEvent) {
}
}
function habitProgressText(h: Habit) {
if (h.kind !== 'numeric') return isDone(h, todayKey.value) ? '今天已完成 · 左滑取消' : '右滑整行打卡'
const value = Number(logFor(h, todayKey.value)?.value ?? 0)
return `${value} / ${h.target ?? 1} · ${isDone(h, todayKey.value) ? '左滑减少一次' : '右滑完成一次'}`
}
function habitActionText(h: Habit) {
if (h.kind !== 'numeric') return isDone(h, todayKey.value) ? '已完成' : '打卡'
if (h.kind !== 'numeric') return ''
return `${Number(logFor(h, todayKey.value)?.value ?? 0)} / ${h.target ?? 1}`
}
@@ -195,50 +173,6 @@ async function loadSettings() {
audit.value = mergePage<any>(a).items
})
}
async function loadCalendarTab() {
await safe(async () => {
const [subs] = await Promise.all([request('/calendar-subscriptions').catch(() => [])])
calendarSubscriptions.value = (subs as CalendarSubscription[]) ?? []
await loadTodayEvents()
})
}
async function addCalendarSubscription() {
if (!calendarUrl.value.trim()) return
await safe(async () => {
await request('/calendar-subscriptions', {
method: 'POST',
body: JSON.stringify({ name: calendarName.value.trim() || calendarUrl.value, url: calendarUrl.value.trim(), color: '#f15a29' }),
})
await loadCalendarTab()
emit('notice', '日历已订阅')
})
}
async function removeCalendarSubscription(id: string) {
if (!confirm('取消这个日历订阅?')) return
await safe(async () => {
await request(`/calendar-subscriptions/${id}`, { method: 'DELETE' })
await loadCalendarTab()
emit('notice', '已取消订阅')
})
}
function habitTimeText(h: Habit) {
return isDone(h, todayKey.value) ? '✓ 已完成' : '未完成'
}
async function loadTodayEvents() {
if (!calendarSubscriptions.value.length) {
todayEvents.value = []
return
}
try {
const events = await request(`/calendar-subscriptions/today?day=${todayKey.value}`) as CalendarEvent[]
todayEvents.value = events ?? []
} catch { todayEvents.value = [] }
}
function formatEventTime(item: CalendarEvent) {
if (item.all_day) return '全天'
const date = new Date(item.starts_at)
return `${date.getHours().toString().padStart(2, '0')}:${date.getMinutes().toString().padStart(2, '0')}`
}
async function revoke(id: string) {
await safe(async () => { await request(`/sessions/${id}`, { method: 'DELETE' }); await loadSettings(); emit('notice', '会话已撤销') })
}
@@ -300,8 +234,8 @@ onBeforeUnmount(() => {
<article v-for="h in todayHabits" :key="h.id" class="habit-row swipeable" :class="{ done: isDone(h, todayKey), 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 class="swipe-bg" :class="{ cancel: isDone(h, todayKey) }">{{ isDone(h, todayKey) ? (h.kind === 'numeric' ? ' 一次' : '↩ 未完成') : (h.kind === 'numeric' ? '+ 一次' : '✓ 打卡') }}</span>
<div class="habit-main">
<span><span class="habit-name">{{ h.name }}</span><small>{{ habitProgressText(h) }}</small></span>
<button class="habit-swipe-hint" :aria-label="isDone(h, todayKey) ? `减少${h.name}一次` : `完成${h.name}一次`" @click.stop="toggleHabitFromButton(h)">{{ habitActionText(h) }}</button>
<span><span class="habit-name">{{ h.name }}</span><small v-if="habitProgressText(h)">{{ habitProgressText(h) }}</small></span>
<button class="sr-only" :aria-label="isDone(h, todayKey) ? `减少${h.name}一次` : `完成${h.name}一次`" @click.stop="toggleHabitFromButton(h)">切换习惯状态</button>
</div>
</article>
<div v-if="!todayHabits.length" class="empty-panel">今天没有安排习惯轻松一下吧</div>
@@ -320,13 +254,13 @@ onBeforeUnmount(() => {
</form>
<!-- 习惯列表布尔习惯整行右滑打卡数值习惯保留明确输入记录 -->
<!-- 习惯列表支持整行滑动记录 -->
<div v-if="view === 'habits'" class="habit-list">
<article v-for="h in habits" :key="h.id" class="habit-row swipeable" :class="{ done: isDone(h, todayKey), 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 class="swipe-bg" :class="{ cancel: isDone(h, todayKey) }">{{ isDone(h, todayKey) ? (h.kind === 'numeric' ? ' 一次' : '↩ 未完成') : (h.kind === 'numeric' ? '+ 一次' : '✓ 打卡') }}</span>
<div class="habit-main">
<span><span class="habit-name">{{ h.name }}</span><small>{{ habitProgressText(h) }}</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)">{{ habitActionText(h) }}</button>
<span><span class="habit-name">{{ h.name }}</span><small v-if="habitProgressText(h)">{{ habitProgressText(h) }}</small></span>
<button class="sr-only" :aria-label="isDone(h, todayKey) ? `减少${h.name}一次` : `完成${h.name}一次`" :aria-pressed="isDone(h, todayKey)" @click.stop="toggleHabitFromButton(h)">切换习惯状态</button>
</div>
<button class="icon ghost" aria-label="归档习惯" @click="archiveHabit(h)"><Trash2 /></button>
</article>
+15 -1
View File
@@ -1,5 +1,5 @@
import { describe, expect, it } from 'vitest'
import { dateKey, defaultView, habitWeek, isHabitComplete, isHabitScheduledToday, isTaskView, nextHabitSwipeValue, numericHabitInputValue, previousHabitSwipeValue, shouldToggleRowSwipe } from './mvp-utils'
import { dateKey, defaultView, habitWeek, isHabitComplete, isHabitScheduledToday, isTaskView, nextHabitSwipeValue, numericHabitInputValue, previousHabitSwipeValue, quickTaskFields, shouldToggleRowSwipe } from './mvp-utils'
describe('MVP view utilities', () => {
it('formats a local date as YYYY-MM-DD', () => {
@@ -33,6 +33,20 @@ describe('MVP view utilities', () => {
expect(defaultView()).toBe('today')
})
it('quick-adds Today tasks to Inbox at noon in Asia/Shanghai', () => {
const now = new Date('2026-09-05T16:30:00.000Z')
expect(quickTaskFields('today', 'selected-list', 'inbox-list', now)).toEqual({
list_id: 'inbox-list',
due_at: '2026-09-06T12:00:00+08:00',
})
})
it('keeps quick-add tasks in the selected list outside Today', () => {
expect(quickTaskFields('tasks', 'selected-list', 'inbox-list', new Date('2026-09-05T16:30:00.000Z'))).toEqual({
list_id: 'selected-list',
})
})
it('only includes scheduled, unpaused habits today regardless of kind or completion', () => {
const day = '2026-09-06'
expect(isHabitScheduledToday({ kind: 'boolean', cells: [{ day, scheduled: true, paused: false, value: true }] }, day)).toBe(true)
+15
View File
@@ -28,6 +28,21 @@ export function defaultView() {
return 'today' as const
}
export function quickTaskFields(view: string, activeList: string, inboxList: string, now = new Date()) {
if (view !== 'today') return { list_id: activeList }
const parts = new Intl.DateTimeFormat('en-CA', {
timeZone: 'Asia/Shanghai',
year: 'numeric',
month: '2-digit',
day: '2-digit',
}).formatToParts(now)
const value = (type: Intl.DateTimeFormatPartTypes) => parts.find((part) => part.type === type)?.value
return {
list_id: inboxList,
due_at: `${value('year')}-${value('month')}-${value('day')}T12:00:00+08:00`,
}
}
export function isHabitComplete(kind: string | undefined, value: number | boolean | undefined, target = 1) {
if (kind === 'numeric') return Number(value ?? 0) >= target
return Boolean(value)
+2 -4
View File
@@ -7,7 +7,6 @@ type SearchTask = {
description?: string
parent_id?: string | null
completed?: boolean
tags?: { name: string }[]
list_name?: string
}
@@ -35,12 +34,11 @@ describe('task utilities', () => {
expect(html).not.toContain('<script>')
})
it('filters titles, descriptions, tags, and list names', () => {
it('filters titles, descriptions, and list names', () => {
const searchable: SearchTask[] = [
...tasks,
{ id: '4', title: 'Plan', tags: [{ name: '重要' }], list_name: '工作清单' },
{ id: '4', title: 'Plan', list_name: '工作清单' },
]
expect(filterTasks(searchable, '重要').map((task) => task.id)).toEqual(['4'])
expect(filterTasks(searchable, '工作').map((task) => task.id)).toEqual(['4'])
})
-2
View File
@@ -4,7 +4,6 @@ export type MinimalTask = {
description?: string
parent_id?: string | null
completed?: boolean
tags?: { name: string }[]
list_name?: string
}
@@ -75,7 +74,6 @@ export function filterTasks<T extends MinimalTask>(tasks: T[], query: string) {
task.title,
task.description ?? '',
task.list_name ?? '',
...(task.tags ?? []).map((tag) => tag.name),
].join(' ')
return haystack.toLowerCase().includes(q)
})
+5 -7
View File
@@ -4,23 +4,21 @@
*{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)}.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-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}
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}.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}.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}.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}}
@media(max-width:800px){.shell{height:100dvh;display:block;overflow:auto}.sidebar,.detail{position:fixed;z-index:30;display:flex;top:0;bottom:0;transition:transform .22s ease;box-shadow:var(--shadow)}.sidebar{left:0;width:min(300px,86vw);transform:translateX(-105%)}.sidebar.open{transform:none}.detail{right:0;width:min(430px,94vw);transform:translateX(105%)}.detail.open{transform:none}.scrim{position:fixed;z-index:20;inset:0;background:rgba(45,38,31,.26)}.mobile-only{display:grid}main{min-height:100dvh;padding:20px 17px 112px}.topbar h1{font-size:24px;margin-bottom:17px}.search{width:auto;margin-bottom:13px;padding:8px}.search input{width:90px}.search kbd{display:none}.quick button{display:none}.row-actions{opacity:1}.row-actions button{padding:7px;min-height:34px}.list-row{padding:6px 4px}.archived-row{padding:9px 7px;border:1px dashed #e0cfae;border-radius:8px;color:#8a6d3b;background:#fbf3e2}.archived-row>svg{color:#c9a45c}.bottom{display:flex;position:fixed;z-index:15;left:0;right:0;bottom:0;justify-content:space-around;background:rgba(255,253,248,.96);border-top:1px solid var(--line);padding:8px 5px max(8px,env(safe-area-inset-bottom));box-shadow:0 -5px 18px rgba(79,59,34,.06)}.bottom button{min-width:60px;border:0;background:transparent;color:#81786d;display:grid;place-items:center;gap:2px;font-size:10px}.bottom button.active{color:var(--accent);font-weight:700}.bottom svg{width:20px}.fab{display:grid;place-items:center;position:fixed;z-index:16;right:18px;bottom:76px;width:52px;height:52px;border:0;border-radius:50%;background:var(--accent);color:#fff;box-shadow:0 7px 20px rgba(241,90,41,.38);transition:transform .15s}.fab:active{transform:scale(.94)}.toast,.error-toast{bottom:142px}.task-row{padding-inline:2px}.subtask{padding-left:35px}.ghost{opacity:.45}}
@media(max-width:800px){.shell{height:100dvh;display:block;overflow:auto}.sidebar{position:fixed;z-index:30;display:flex;top:0;bottom:0;transition:transform .22s ease;box-shadow:var(--shadow);left:0;width:min(300px,86vw);transform:translateX(-105%)}.sidebar.open{transform:none}.detail{position:fixed;z-index:40;display:block;left:0;right:0;top:auto;bottom:0;width:100%;max-height:min(88dvh,760px);overflow:auto;background:#fffdf8;border:1px solid var(--line);border-bottom:0;border-radius:22px 22px 0 0;box-shadow:0 -14px 38px rgba(56,40,24,.2);transform:translateY(105%);transition:transform .22s ease;padding-bottom:max(16px,env(safe-area-inset-bottom))}.detail.open{transform:none}.detail-head{position:sticky;z-index:2;top:0;height:62px;background:#fffdf8;border-bottom:1px solid var(--line);padding:0 18px}.detail-head .icon{display:grid;width:42px;height:42px;background:var(--accent-soft);color:var(--accent);border-radius:50%}.detail-form{padding:18px}.detail-title textarea{font-size:21px}.more-mask{position:fixed;z-index:45;inset:0;background:rgba(45,38,31,.32);display:flex;align-items:flex-end}.more-sheet{width:100%;background:#fffdf8;border-radius:22px 22px 0 0;padding:14px 16px max(22px,env(safe-area-inset-bottom));display:grid;gap:6px}.more-sheet-head{display:flex;align-items:center;justify-content:space-between;padding:2px 4px 8px}.more-sheet>button{display:flex;align-items:center;gap:12px;border:0;background:#fff;padding:13px;border-radius:12px;text-align:left}.scrim{position:fixed;z-index:35;inset:0;background:rgba(45,38,31,.32)}.mobile-only{display:grid}main{min-height:100dvh;padding:20px 17px 112px}.topbar h1{font-size:24px;margin:0 0 17px}.search{width:auto;margin-bottom:13px;padding:8px}.search input{width:90px}.search kbd{display:none}.quick button{display:none}.row-actions{opacity:1}.row-actions button{padding:7px;min-height:34px}.list-row{padding:6px 4px}.archived-row{padding:9px 7px;border:1px dashed #e0cfae;border-radius:8px;color:#8a6d3b;background:#fbf3e2}.archived-row>svg{color:#c9a45c}.bottom{display:flex;position:fixed;z-index:15;left:0;right:0;bottom:0;justify-content:space-around;background:rgba(255,253,248,.96);border-top:1px solid var(--line);padding:8px 5px max(8px,env(safe-area-inset-bottom));box-shadow:0 -5px 18px rgba(79,59,34,.06)}.bottom button{min-width:60px;border:0;background:transparent;color:#81786d;display:grid;place-items:center;gap:2px;font-size:10px}.bottom button.active{color:var(--accent);font-weight:700}.bottom svg{width:20px}.fab{display:grid;place-items:center;position:fixed;z-index:16;right:18px;bottom:76px;width:52px;height:52px;border:0;border-radius:50%;background:var(--accent);color:#fff;box-shadow:0 7px 20px rgba(241,90,41,.38);transition:transform .15s}.fab:active{transform:scale(.94)}.toast,.error-toast{bottom:142px}.task-row{padding-inline:2px}.subtask{padding-left:35px}.ghost{opacity:.45}}
.mvp-view{display:grid;gap:16px;padding-bottom:36px}.view-intro{display:flex;align-items:end;justify-content:space-between;border-bottom:1px dashed var(--line);padding-bottom:12px}.view-intro h2{margin:2px 0 0;font-size:22px}.view-intro small,.view-intro>span{color:var(--muted);font-size:12px}.tool-card,.empty-panel{background:#fff;border:1px solid var(--line);border-radius:12px;padding:16px;box-shadow:0 3px 14px rgba(81,61,38,.05)}.soft-button,.primary-small,.danger-button,.file-button{display:inline-flex;align-items:center;justify-content:center;gap:6px;border:1px solid var(--line);background:#fff;border-radius:8px;padding:8px 11px;font-size:12px}.soft-button svg,.file-button svg{width:15px}.primary-small{background:var(--accent);border-color:var(--accent);color:#fff}.danger-button{color:var(--danger);border-color:#e5b7ad}.habit-create{display:grid;grid-template-columns:1fr 150px 90px auto;gap:8px}.habit-create input,.habit-create select{min-width:0;border:1px solid var(--line);border-radius:8px;background:#fff;padding:9px}.habit-create button{border:0;border-radius:8px;background:var(--accent);color:#fff;padding:8px 13px;display:flex;align-items:center;gap:5px}.habit-list{display:grid;gap:10px}.tool-card h3{margin:0 0 4px}
.habit-row{--swipe-x:0px;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,transform .18s ease;position:relative;overflow:hidden;isolation:isolate}
.habit-row.done{border-color:var(--accent)}
.habit-row.done{background:#fffaf5}
.habit-row.swipeable{touch-action:pan-y;transform:translateX(var(--swipe-x))}
.habit-row.swipeable:not([style*="0px"]){transition:none}
.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-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)}
@@ -30,4 +28,4 @@ main{min-width:0;padding:27px 34px 50px;overflow:auto;background:linear-gradient
.settings-grid{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:12px}.tool-card{display:flex;flex-direction:column;align-items:flex-start;gap:10px}.tool-card>svg{color:var(--accent);width:25px;height:25px}.tool-card p{margin:0;color:var(--muted);font-size:13px}.tool-card.wide{grid-column:1/-1}.file-button input{display:none}.tool-card pre{width:100%;max-height:180px;overflow:auto;background:#f8f3e8;padding:10px;border-radius:8px;font-size:10px}.session-row,.audit-row{width:100%;display:flex;justify-content:space-between;align-items:center;border-top:1px solid var(--line);padding:9px 0}.session-row span{display:grid}.session-row small,.audit-row small{color:var(--muted);font-size:11px}.inline-error{padding:9px;border-radius:8px;color:var(--danger);background:#fff0ed}.settings.active{background:var(--accent-soft);color:#b7421e;font-weight:700}
@media(max-width:800px){.habit-create{grid-template-columns:1fr 1fr}.habit-create button{justify-content:center}.settings-grid{grid-template-columns:1fr}.tool-card.wide{grid-column:auto}.habit-row{padding:5px 4px 5px 8px}.habit-main{padding:10px 2px}.numeric-action input{width:62px}}
@media(prefers-reduced-motion:reduce){*,*:before,*:after{scroll-behavior:auto!important;animation-duration:.01ms!important;transition-duration:.01ms!important}}
.pager{display:flex;align-items:center;justify-content:flex-end;gap:10px;margin:0 0 14px}.pager button:disabled{opacity:.4;cursor:not-allowed}.pager span{color:var(--muted);font-size:13px}.modal-mask{position:fixed;inset:0;z-index:80;background:rgba(45,38,31,.4);display:grid;place-items:center;padding:24px}.modal-box{width:min(360px,92vw);background:#fffdf8;border:1px solid var(--line);border-radius:14px;box-shadow:var(--shadow);padding:22px;display:grid;gap:14px}.modal-box h3{margin:0;font-size:17px}.modal-box label{display:grid;gap:7px;font-size:12px;font-weight:650;color:#756d61}.modal-input{width:100%;border:1px solid var(--line);background:#fff;padding:11px;border-radius:9px;outline:none;font-size:16px}.modal-actions{display:flex;justify-content:flex-end;gap:8px}.calendar-events{display:grid;gap:8px}.calendar-event{display:grid;grid-template-columns:auto 1fr;align-items:baseline;gap:3px 10px;padding:9px 4px;border-bottom:1px dashed var(--line)}.calendar-event b{font-size:14px}.calendar-event small{grid-column:2;color:var(--muted);font-size:11px}.calendar-event .ev-time{grid-row:1/3;align-self:center;min-width:46px;color:var(--accent);font-weight:800;font-size:12px}
.pager{display:flex;align-items:center;justify-content:flex-end;gap:10px;margin:0 0 14px}.pager button:disabled{opacity:.4;cursor:not-allowed}.pager span{color:var(--muted);font-size:13px}.sr-only{position:absolute!important;width:1px!important;height:1px!important;padding:0!important;margin:-1px!important;overflow:hidden!important;clip:rect(0,0,0,0)!important;white-space:nowrap!important;border:0!important}.more-settings{border:1px solid var(--line);border-radius:10px;background:#fff}.more-settings summary{cursor:pointer;padding:11px 13px;color:#756d61;font-size:12px;font-weight:700}.more-settings-body{display:grid;gap:12px;padding:0 13px 13px}.more-settings-body>label{display:grid;grid-template-columns:80px 1fr;align-items:center;gap:7px;color:#756d61;font-size:12px;font-weight:650}.more-mask{display:none}.modal-mask{position:fixed;inset:0;z-index:80;background:rgba(45,38,31,.4);display:grid;place-items:center;padding:24px}.modal-box{width:min(360px,92vw);background:#fffdf8;border:1px solid var(--line);border-radius:14px;box-shadow:var(--shadow);padding:22px;display:grid;gap:14px}.modal-box h3{margin:0;font-size:17px}.modal-box label{display:grid;gap:7px;font-size:12px;font-weight:650;color:#756d61}.modal-input{width:100%;border:1px solid var(--line);background:#fff;padding:11px;border-radius:9px;outline:none;font-size:16px}.modal-actions{display:flex;justify-content:flex-end;gap:8px}