style: refine dense task views
This commit is contained in:
@@ -0,0 +1,109 @@
|
|||||||
|
import type { APIRequestContext, Locator, Page } from '@playwright/test'
|
||||||
|
import { expect, test } from './fixtures'
|
||||||
|
|
||||||
|
async function csrf(request: APIRequestContext) {
|
||||||
|
const state = await request.storageState()
|
||||||
|
return state.cookies.find(cookie => cookie.name === 'dodo_csrf')?.value ?? ''
|
||||||
|
}
|
||||||
|
|
||||||
|
async function mutate(request: APIRequestContext, baseURL: string, path: string, options: Parameters<APIRequestContext['fetch']>[1]) {
|
||||||
|
return request.fetch(path, { ...options, headers: { ...options?.headers, 'x-csrf-token': await csrf(request), origin: baseURL } })
|
||||||
|
}
|
||||||
|
|
||||||
|
async function expectTaskRowGeometry(row: Locator) {
|
||||||
|
const metrics = await row.evaluate((element: HTMLElement) => {
|
||||||
|
const controls = [...element.querySelectorAll<HTMLElement>('button')].map(control => {
|
||||||
|
const rect = control.getBoundingClientRect()
|
||||||
|
return { width: rect.width, height: rect.height }
|
||||||
|
})
|
||||||
|
const tail = element.querySelector<HTMLElement>('.task-tail')
|
||||||
|
const tailRect = tail?.getBoundingClientRect()
|
||||||
|
const rowRect = element.getBoundingClientRect()
|
||||||
|
const directChildrenInside = [...element.children].every(child => {
|
||||||
|
const rect = (child as HTMLElement).getBoundingClientRect()
|
||||||
|
return rect.left >= rowRect.left && rect.right <= rowRect.right && rect.top >= rowRect.top && rect.bottom <= rowRect.bottom
|
||||||
|
})
|
||||||
|
return {
|
||||||
|
height: rowRect.height,
|
||||||
|
clientWidth: element.clientWidth,
|
||||||
|
scrollWidth: element.scrollWidth,
|
||||||
|
controls,
|
||||||
|
tailInside: !tailRect || (tailRect.left >= rowRect.left && tailRect.right <= rowRect.right),
|
||||||
|
directChildrenInside,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
expect(metrics.height).toBeGreaterThanOrEqual(64)
|
||||||
|
expect(metrics.height).toBeLessThanOrEqual(68)
|
||||||
|
expect(metrics.scrollWidth).toBeLessThanOrEqual(metrics.clientWidth)
|
||||||
|
expect(metrics.tailInside).toBe(true)
|
||||||
|
expect(metrics.directChildrenInside).toBe(true)
|
||||||
|
for (const control of metrics.controls) {
|
||||||
|
expect(control.width).toBeGreaterThanOrEqual(44)
|
||||||
|
expect(control.height).toBeGreaterThanOrEqual(44)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function openInbox(page: Page) {
|
||||||
|
if ((await page.viewportSize())!.width <= 930) {
|
||||||
|
await page.locator('.topbar').getByRole('button', { name: /展开菜单|收起菜单/ }).click()
|
||||||
|
}
|
||||||
|
await page.locator('.sidebar').getByRole('button', { name: '收集箱', exact: true }).click()
|
||||||
|
}
|
||||||
|
|
||||||
|
test('task rows keep approved rhythm without clipping across desktop and mobile', async ({ page, request, baseURL }) => {
|
||||||
|
const suffix = `${test.info().project.name}-${Date.now()}`
|
||||||
|
const bootstrap = await request.get('/api/v1/bootstrap')
|
||||||
|
expect(bootstrap.ok()).toBeTruthy()
|
||||||
|
const inbox = (await bootstrap.json()).lists.find((item: { is_inbox: boolean }) => item.is_inbox)
|
||||||
|
|
||||||
|
const create = async (title: string, extra: Record<string, unknown> = {}) => {
|
||||||
|
const response = await mutate(request, baseURL!, '/api/v1/tasks', {
|
||||||
|
method: 'POST', data: { title, list_id: inbox.id, ...extra },
|
||||||
|
})
|
||||||
|
expect(response.ok(), await response.text()).toBeTruthy()
|
||||||
|
return response.json() as Promise<{ id: string }>
|
||||||
|
}
|
||||||
|
|
||||||
|
const ordinaryTitle = `普通任务-${suffix}`
|
||||||
|
const completedTitle = `完成任务-${suffix}`
|
||||||
|
const longTitle = `超长任务-${suffix}-` + '这是一段用于确认标题省略且不撑高任务行的连续文字'.repeat(5)
|
||||||
|
const dueTitle = `含截止日期-${suffix}`
|
||||||
|
const parentTitle = `含子任务-${suffix}`
|
||||||
|
const localDate = (offset: number) => {
|
||||||
|
const date = new Date()
|
||||||
|
date.setDate(date.getDate() + offset)
|
||||||
|
return `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}-${String(date.getDate()).padStart(2, '0')}`
|
||||||
|
}
|
||||||
|
await create(ordinaryTitle)
|
||||||
|
await create(completedTitle)
|
||||||
|
await create(longTitle)
|
||||||
|
await create(dueTitle, { due_at: `${localDate(1)}T23:59:00`, due_has_time: true })
|
||||||
|
const parent = await create(parentTitle)
|
||||||
|
await create(`子任务-${suffix}`, { parent_id: parent.id })
|
||||||
|
|
||||||
|
await page.setViewportSize({ width: 1440, height: 900 })
|
||||||
|
await page.goto('/')
|
||||||
|
await openInbox(page)
|
||||||
|
|
||||||
|
const titles = [ordinaryTitle, completedTitle, longTitle, dueTitle, parentTitle]
|
||||||
|
for (const title of titles) {
|
||||||
|
const row = page.locator('.task-row').filter({ hasText: title })
|
||||||
|
await expect(row).toHaveCount(1)
|
||||||
|
await expectTaskRowGeometry(row)
|
||||||
|
}
|
||||||
|
const completedRow = page.locator('.task-row').filter({ hasText: completedTitle })
|
||||||
|
await completedRow.getByRole('button', { name: `完成${completedTitle}` }).click()
|
||||||
|
await expect(completedRow).toHaveClass(/done/)
|
||||||
|
await expectTaskRowGeometry(completedRow)
|
||||||
|
await expect(page.locator('.task-row').filter({ hasText: dueTitle }).locator('.task-tail')).toBeVisible()
|
||||||
|
await expect(page.locator('.task-row').filter({ hasText: parentTitle })).toContainText('0/1')
|
||||||
|
|
||||||
|
for (const viewport of [{ width: 390, height: 844 }, { width: 375, height: 667 }]) {
|
||||||
|
await page.setViewportSize(viewport)
|
||||||
|
for (const title of titles) {
|
||||||
|
await expectTaskRowGeometry(page.locator('.task-row').filter({ hasText: title }))
|
||||||
|
}
|
||||||
|
const pageMetrics = await page.evaluate(() => ({ clientWidth: document.documentElement.clientWidth, scrollWidth: document.documentElement.scrollWidth }))
|
||||||
|
expect(pageMetrics.scrollWidth).toBeLessThanOrEqual(pageMetrics.clientWidth)
|
||||||
|
}
|
||||||
|
})
|
||||||
@@ -0,0 +1,157 @@
|
|||||||
|
import type { APIRequestContext, Locator, Page } from '@playwright/test'
|
||||||
|
import { expect, test } from './fixtures'
|
||||||
|
|
||||||
|
async function csrf(request: APIRequestContext) {
|
||||||
|
const state = await request.storageState()
|
||||||
|
return state.cookies.find(cookie => cookie.name === 'dodo_csrf')?.value ?? ''
|
||||||
|
}
|
||||||
|
|
||||||
|
async function mutate(request: APIRequestContext, baseURL: string, path: string, options: Parameters<APIRequestContext['fetch']>[1]) {
|
||||||
|
return request.fetch(path, { ...options, headers: { ...options?.headers, 'x-csrf-token': await csrf(request), origin: baseURL } })
|
||||||
|
}
|
||||||
|
|
||||||
|
function bottomTab(page: Page, name: string) {
|
||||||
|
return page.getByRole('navigation', { name: '主要导航' }).getByRole('button', { name, exact: true })
|
||||||
|
}
|
||||||
|
|
||||||
|
async function openSidebarView(page: Page, name: string) {
|
||||||
|
await page.locator('.topbar').getByRole('button', { name: /展开菜单|收起菜单/ }).click()
|
||||||
|
const target = page.locator('.sidebar').getByRole('button', { name, exact: true })
|
||||||
|
await expect(target).toBeInViewport()
|
||||||
|
await target.click()
|
||||||
|
}
|
||||||
|
|
||||||
|
async function expectHeightInRange(locator: Locator, minimum: number, maximum: number) {
|
||||||
|
const box = await locator.boundingBox()
|
||||||
|
expect(box).not.toBeNull()
|
||||||
|
expect(box!.height).toBeGreaterThanOrEqual(minimum)
|
||||||
|
expect(box!.height).toBeLessThanOrEqual(maximum)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function expectNotClipped(locator: Locator) {
|
||||||
|
const metrics = await locator.evaluate((element: HTMLElement) => ({
|
||||||
|
clientWidth: element.clientWidth,
|
||||||
|
scrollWidth: element.scrollWidth,
|
||||||
|
clientHeight: element.clientHeight,
|
||||||
|
scrollHeight: element.scrollHeight,
|
||||||
|
}))
|
||||||
|
expect(metrics.scrollWidth).toBeLessThanOrEqual(metrics.clientWidth)
|
||||||
|
expect(metrics.scrollHeight).toBeLessThanOrEqual(metrics.clientHeight)
|
||||||
|
}
|
||||||
|
|
||||||
|
test('approved polish keeps search state, dense rows, title-only memos, and unique detail titles', async ({ page, request, baseURL }, testInfo) => {
|
||||||
|
const suffix = testInfo.project.name
|
||||||
|
const bootstrapResponse = await request.get('/api/v1/bootstrap')
|
||||||
|
expect(bootstrapResponse.ok()).toBeTruthy()
|
||||||
|
const inbox = (await bootstrapResponse.json()).lists.find((item: { is_inbox: boolean }) => item.is_inbox)
|
||||||
|
expect(inbox).toBeTruthy()
|
||||||
|
|
||||||
|
const taskTitle = `搜索保留-${suffix}-这是用于检查长标题和右侧控件不会裁切的任务标题`
|
||||||
|
const taskResponse = await mutate(request, baseURL!, '/api/v1/tasks', {
|
||||||
|
method: 'POST',
|
||||||
|
data: { title: taskTitle, list_id: inbox.id },
|
||||||
|
})
|
||||||
|
expect(taskResponse.ok(), await taskResponse.text()).toBeTruthy()
|
||||||
|
|
||||||
|
const memoTitle = `Markdown 摘要-${suffix}`
|
||||||
|
const memoResponse = await mutate(request, baseURL!, '/api/v1/memos', {
|
||||||
|
method: 'POST',
|
||||||
|
data: { title: memoTitle, content: '# 标题\n\n**重点** 与 [链接文字](https://example.com)\n\n- 列表项' },
|
||||||
|
})
|
||||||
|
expect(memoResponse.ok(), await memoResponse.text()).toBeTruthy()
|
||||||
|
|
||||||
|
const day = new Date().toLocaleDateString('sv-SE')
|
||||||
|
const countdownTitle = `紧凑倒数-${suffix}`
|
||||||
|
const countdownResponse = await mutate(request, baseURL!, '/api/v1/countdowns', {
|
||||||
|
method: 'POST',
|
||||||
|
data: { title: countdownTitle, event_date: day, kind: 'countdown', repeat_rule: 'none', calendar_mode: 'solar', ignore_year: false, pinned: true },
|
||||||
|
})
|
||||||
|
expect(countdownResponse.ok(), await countdownResponse.text()).toBeTruthy()
|
||||||
|
|
||||||
|
await page.goto('/')
|
||||||
|
await openSidebarView(page, '收集箱')
|
||||||
|
const searchToggle = page.getByRole('button', { name: '展开搜索任务' })
|
||||||
|
await expect(searchToggle).toHaveAttribute('aria-expanded', 'false')
|
||||||
|
await searchToggle.click()
|
||||||
|
const searchInput = page.getByRole('textbox', { name: '搜索任务' })
|
||||||
|
await expect(searchInput).toBeFocused()
|
||||||
|
await searchInput.fill(`搜索保留-${suffix}`)
|
||||||
|
const taskRow = page.locator('.task-row').filter({ hasText: taskTitle })
|
||||||
|
await expect(taskRow).toHaveCount(1)
|
||||||
|
await expectHeightInRange(taskRow, 64, 68)
|
||||||
|
await expectNotClipped(taskRow)
|
||||||
|
await searchInput.press('Escape')
|
||||||
|
const collapsedToggle = page.getByRole('button', { name: '展开搜索任务' })
|
||||||
|
await expect(collapsedToggle).toBeFocused()
|
||||||
|
await expect(collapsedToggle).toHaveAttribute('aria-expanded', 'false')
|
||||||
|
await collapsedToggle.click()
|
||||||
|
await expect(searchInput).toHaveValue(`搜索保留-${suffix}`)
|
||||||
|
|
||||||
|
await bottomTab(page, '习惯').click()
|
||||||
|
const habitName = `长标题习惯-${suffix}-检查进度与按钮不被裁切`
|
||||||
|
await page.getByRole('button', { name: '添加习惯' }).click()
|
||||||
|
await page.getByLabel('新习惯名称').fill(habitName)
|
||||||
|
await page.getByLabel('习惯类型').selectOption('numeric')
|
||||||
|
await page.getByLabel('目标值').fill('8')
|
||||||
|
await page.getByRole('button', { name: '添加习惯', exact: true }).click()
|
||||||
|
const habitRow = page.locator('.habit-row').filter({ hasText: habitName })
|
||||||
|
await expect(habitRow).toHaveCount(1)
|
||||||
|
await expectHeightInRange(habitRow, 84, 88)
|
||||||
|
await expectNotClipped(habitRow)
|
||||||
|
await habitRow.getByRole('button', { name: `查看习惯详情:${habitName}` }).click()
|
||||||
|
const habitDetail = page.getByRole('dialog', { name: habitName })
|
||||||
|
await expect(habitDetail.getByRole('heading', { name: habitName, exact: true })).toHaveCount(1)
|
||||||
|
await expect(habitDetail).not.toContainText('习惯详情')
|
||||||
|
await habitDetail.getByRole('button', { name: '关闭习惯详情' }).click()
|
||||||
|
|
||||||
|
await bottomTab(page, '倒数日').click()
|
||||||
|
const focus = page.locator('.countdown-focus').filter({ hasText: countdownTitle })
|
||||||
|
await expect(focus).toHaveCount(1)
|
||||||
|
await expectHeightInRange(focus, 136, 148)
|
||||||
|
await expect(focus).not.toContainText(/置顶的重要日子|下一个重要日子|还有|已经过去/)
|
||||||
|
await expectNotClipped(focus)
|
||||||
|
await focus.click()
|
||||||
|
const countdownDetail = page.getByRole('dialog', { name: countdownTitle })
|
||||||
|
await expect(countdownDetail.getByRole('heading', { name: countdownTitle, exact: true })).toHaveCount(1)
|
||||||
|
await expect(countdownDetail).not.toContainText('重要日子详情')
|
||||||
|
await countdownDetail.getByRole('button', { name: '关闭详情' }).click()
|
||||||
|
|
||||||
|
await openSidebarView(page, '备忘录')
|
||||||
|
const memoRow = page.locator('.memo-row').filter({ hasText: memoTitle })
|
||||||
|
await expect(memoRow).toHaveCount(1)
|
||||||
|
await expectHeightInRange(memoRow, 72, 76)
|
||||||
|
await expect(memoRow.locator('.memo-row__excerpt')).toHaveCount(0)
|
||||||
|
await expect(memoRow).not.toContainText('标题 重点 与 链接文字 列表项')
|
||||||
|
await expect(memoRow.getByText(memoTitle, { exact: true })).toHaveCount(1)
|
||||||
|
await expect(memoRow.locator('time')).toHaveCount(1)
|
||||||
|
await expectNotClipped(memoRow)
|
||||||
|
|
||||||
|
const shortMemoTitle = `短正文-${suffix}`
|
||||||
|
const shortMemoResponse = await mutate(request, baseURL!, '/api/v1/memos', {
|
||||||
|
method: 'POST',
|
||||||
|
data: { title: shortMemoTitle, content: 'BODY_ONLY_SHORT' },
|
||||||
|
})
|
||||||
|
expect(shortMemoResponse.ok(), await shortMemoResponse.text()).toBeTruthy()
|
||||||
|
await page.reload()
|
||||||
|
const shortMemoRow = page.locator('.memo-row').filter({ hasText: shortMemoTitle })
|
||||||
|
await expect(shortMemoRow).toHaveCount(1)
|
||||||
|
await expectHeightInRange(shortMemoRow, 72, 76)
|
||||||
|
await expect(shortMemoRow.locator('.memo-row__excerpt')).toHaveCount(0)
|
||||||
|
await expect(shortMemoRow).not.toContainText('BODY_ONLY_SHORT')
|
||||||
|
|
||||||
|
await page.setViewportSize({ width: 1440, height: 900 })
|
||||||
|
const desktopInbox = page.locator('.sidebar').getByRole('button', { name: '收集箱', exact: true })
|
||||||
|
await expect(desktopInbox).toBeInViewport()
|
||||||
|
await desktopInbox.click()
|
||||||
|
await expect(page.getByRole('button', { name: /展开搜索任务|收起搜索任务/ })).toBeHidden()
|
||||||
|
const desktopSearch = page.getByRole('textbox', { name: '搜索任务' })
|
||||||
|
await expect(desktopSearch).toBeVisible()
|
||||||
|
const desktopPanel = page.locator('.search-reveal')
|
||||||
|
const desktopBox = await desktopPanel.boundingBox()
|
||||||
|
expect(desktopBox).not.toBeNull()
|
||||||
|
expect(desktopBox!.width).toBeGreaterThanOrEqual(320)
|
||||||
|
await desktopSearch.fill(`搜索保留-${suffix}`)
|
||||||
|
const desktopTaskRow = page.locator('.task-row').filter({ hasText: taskTitle })
|
||||||
|
await expect(desktopTaskRow).toHaveCount(1)
|
||||||
|
await expectNotClipped(desktopTaskRow)
|
||||||
|
})
|
||||||
+19
-2
@@ -76,6 +76,7 @@ let listHandlePending: { id: string; pointer: ListDragPointer } | undefined
|
|||||||
let suppressListClickId = ''
|
let suppressListClickId = ''
|
||||||
const query = ref('')
|
const query = ref('')
|
||||||
const searchInput = ref<HTMLInputElement | null>(null)
|
const searchInput = ref<HTMLInputElement | null>(null)
|
||||||
|
const taskSearchToggle = ref<HTMLButtonElement | null>(null)
|
||||||
const mobileSearchOpen = ref(false)
|
const mobileSearchOpen = ref(false)
|
||||||
const searchPullDistance = ref(0)
|
const searchPullDistance = ref(0)
|
||||||
let searchTouchStartY: number | null = null
|
let searchTouchStartY: number | null = null
|
||||||
@@ -1344,6 +1345,21 @@ function openTaskSearch() {
|
|||||||
searchPullDistance.value = 0
|
searchPullDistance.value = 0
|
||||||
void nextTick(() => searchInput.value?.focus())
|
void nextTick(() => searchInput.value?.focus())
|
||||||
}
|
}
|
||||||
|
function closeTaskSearch() {
|
||||||
|
if (!isMobileSearchLayout()) return
|
||||||
|
mobileSearchOpen.value = false
|
||||||
|
searchPullDistance.value = 0
|
||||||
|
void nextTick(() => taskSearchToggle.value?.focus())
|
||||||
|
}
|
||||||
|
function toggleTaskSearch() {
|
||||||
|
if (mobileSearchOpen.value) closeTaskSearch()
|
||||||
|
else openTaskSearch()
|
||||||
|
}
|
||||||
|
function handleTaskSearchKeydown(event: KeyboardEvent) {
|
||||||
|
if (event.key !== 'Escape' || !isMobileSearchLayout()) return
|
||||||
|
event.preventDefault()
|
||||||
|
closeTaskSearch()
|
||||||
|
}
|
||||||
function startSearchPull(event: TouchEvent) {
|
function startSearchPull(event: TouchEvent) {
|
||||||
if (!taskSearchAvailable.value || !isMobileSearchLayout()) return
|
if (!taskSearchAvailable.value || !isMobileSearchLayout()) return
|
||||||
const target = event.target as Element | null
|
const target = event.target as Element | null
|
||||||
@@ -1509,9 +1525,10 @@ onUnmounted(() => {
|
|||||||
<CompletedFilterPill v-model="showCompleted" class="topbar-filter" />
|
<CompletedFilterPill v-model="showCompleted" class="topbar-filter" />
|
||||||
<button class="icon topbar-refresh" :class="{spinning:refreshing}" type="button" aria-label="刷新当前页面" title="刷新" :disabled="refreshing || loading" @click="refreshCurrentView"><RefreshCw /></button>
|
<button class="icon topbar-refresh" :class="{spinning:refreshing}" type="button" aria-label="刷新当前页面" title="刷新" :disabled="refreshing || loading" @click="refreshCurrentView"><RefreshCw /></button>
|
||||||
</div>
|
</div>
|
||||||
<div v-if="taskSearchAvailable" class="search-reveal" :class="{'mobile-search-open':mobileSearchOpen || Boolean(query),'mobile-search-pulling':searchPullDistance>0}" :style="searchRevealStyle">
|
<div v-if="taskSearchAvailable" class="search-reveal" :class="{'mobile-search-open':mobileSearchOpen,'mobile-search-pulling':searchPullDistance>0}" :style="searchRevealStyle">
|
||||||
|
<button ref="taskSearchToggle" class="task-search-toggle" type="button" :aria-label="mobileSearchOpen ? '收起搜索任务' : '展开搜索任务'" :aria-expanded="mobileSearchOpen" aria-controls="task-search-panel" @click="toggleTaskSearch"><Search/></button>
|
||||||
<span class="search-pull-hint" aria-hidden="true">{{searchPullDistance >= 56 ? '松开搜索' : '下拉搜索'}}</span>
|
<span class="search-pull-hint" aria-hidden="true">{{searchPullDistance >= 56 ? '松开搜索' : '下拉搜索'}}</span>
|
||||||
<label class="search"><Search/><input ref="searchInput" v-model="query" placeholder="搜索任务…" aria-label="搜索任务" @blur="handleSearchBlur"><kbd>⌘ K</kbd></label>
|
<label id="task-search-panel" class="search"><Search/><input ref="searchInput" v-model="query" placeholder="搜索任务…" aria-label="搜索任务" @keydown="handleTaskSearchKeydown"><kbd>⌘ K</kbd></label>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
<template v-if="['habits','settings'].includes(activeView)">
|
<template v-if="['habits','settings'].includes(activeView)">
|
||||||
|
|||||||
@@ -187,9 +187,8 @@ onBeforeUnmount(() => {
|
|||||||
<p v-if="error" class="inline-error">{{error}}</p>
|
<p v-if="error" class="inline-error">{{error}}</p>
|
||||||
<div v-if="items.length" class="countdown-layout">
|
<div v-if="items.length" class="countdown-layout">
|
||||||
<button v-if="focusItem" type="button" class="countdown-focus" :class="`kind-${focusItem.kind}`" @click="openDetail(focusItem)">
|
<button v-if="focusItem" type="button" class="countdown-focus" :class="`kind-${focusItem.kind}`" @click="openDetail(focusItem)">
|
||||||
<small>{{focusItem.pinned?'置顶的重要日子':'下一个重要日子'}}</small>
|
|
||||||
<div class="countdown-number"><strong>{{focusItem.days===0?'今天':Math.abs(focusItem.days)}}</strong><span v-if="focusItem.days!==0">天</span></div>
|
<div class="countdown-number"><strong>{{focusItem.days===0?'今天':Math.abs(focusItem.days)}}</strong><span v-if="focusItem.days!==0">天</span></div>
|
||||||
<h3>{{focusItem.title}}</h3><b class="countdown-copy">{{countdownDayText(focusItem.days)}}</b><p>{{primaryDate(focusItem)}}</p>
|
<h3>{{focusItem.title}}</h3><p>{{primaryDate(focusItem)}}</p>
|
||||||
</button>
|
</button>
|
||||||
<div v-if="countdownGroups.length" class="countdown-timeline">
|
<div v-if="countdownGroups.length" class="countdown-timeline">
|
||||||
<section v-for="group in countdownGroups" :key="group.key" class="countdown-group">
|
<section v-for="group in countdownGroups" :key="group.key" class="countdown-group">
|
||||||
@@ -207,7 +206,7 @@ onBeforeUnmount(() => {
|
|||||||
</div>
|
</div>
|
||||||
<AppSheet :open="Boolean(detailItem)" variant="detail" panel-class="countdown-detail-sheet" title-id="countdown-detail-title" initial-focus="button[aria-label='关闭详情']" :busy="busy" @close="closeDetail">
|
<AppSheet :open="Boolean(detailItem)" variant="detail" panel-class="countdown-detail-sheet" title-id="countdown-detail-title" initial-focus="button[aria-label='关闭详情']" :busy="busy" @close="closeDetail">
|
||||||
<template v-if="detailItem">
|
<template v-if="detailItem">
|
||||||
<header class="app-sheet__header"><div><small>重要日子详情</small><h3 id="countdown-detail-title">{{detailItem.title}}</h3></div><button type="button" aria-label="关闭详情" @click="closeDetail"><X/></button></header>
|
<header class="app-sheet__header"><div><h3 id="countdown-detail-title">{{detailItem.title}}</h3></div><button type="button" aria-label="关闭详情" @click="closeDetail"><X/></button></header>
|
||||||
<div class="app-sheet__body"><div class="countdown-detail-days"><strong>{{detailItem.days===0?'今天':Math.abs(detailItem.days)}}</strong><span v-if="detailItem.days!==0">天</span><b>{{countdownDayText(detailItem.days)}}</b></div>
|
<div class="app-sheet__body"><div class="countdown-detail-days"><strong>{{detailItem.days===0?'今天':Math.abs(detailItem.days)}}</strong><span v-if="detailItem.days!==0">天</span><b>{{countdownDayText(detailItem.days)}}</b></div>
|
||||||
<dl><div><dt>日期</dt><dd>{{primaryDate(detailItem)}}</dd></div><div v-if="secondaryDate(detailItem)"><dt>换算</dt><dd>{{secondaryDate(detailItem)}}</dd></div><div><dt>类型</dt><dd>{{countdownKindLabel(detailItem.kind)}} · {{detailItem.calendar_mode==='lunar'?'农历':'公历'}} · {{repeatBadge(detailItem) || '不重复'}}</dd></div></dl></div>
|
<dl><div><dt>日期</dt><dd>{{primaryDate(detailItem)}}</dd></div><div v-if="secondaryDate(detailItem)"><dt>换算</dt><dd>{{secondaryDate(detailItem)}}</dd></div><div><dt>类型</dt><dd>{{countdownKindLabel(detailItem.kind)}} · {{detailItem.calendar_mode==='lunar'?'农历':'公历'}} · {{repeatBadge(detailItem) || '不重复'}}</dd></div></dl></div>
|
||||||
<footer class="app-sheet__footer"><button type="button" :disabled="busy" @click="setPinned(detailItem)"><Pin/>{{detailItem.pinned?'取消置顶':'置顶'}}</button><button type="button" :disabled="busy" @click="edit(detailItem)"><Pencil/>编辑</button><button type="button" class="danger-text" :disabled="busy" @click="archiveItem(detailItem)"><Archive/>归档</button></footer>
|
<footer class="app-sheet__footer"><button type="button" :disabled="busy" @click="setPinned(detailItem)"><Pin/>{{detailItem.pinned?'取消置顶':'置顶'}}</button><button type="button" :disabled="busy" @click="edit(detailItem)"><Pencil/>编辑</button><button type="button" class="danger-text" :disabled="busy" @click="archiveItem(detailItem)"><Archive/>归档</button></footer>
|
||||||
|
|||||||
@@ -444,7 +444,7 @@ describe('MemoPanel', () => {
|
|||||||
expect([...host.querySelectorAll('.memo-row strong')].map((node) => node.textContent)).toEqual(['较高 ID', '同时间更新'])
|
expect([...host.querySelectorAll('.memo-row strong')].map((node) => node.textContent)).toEqual(['较高 ID', '同时间更新'])
|
||||||
})
|
})
|
||||||
|
|
||||||
it('keeps a 120-character excerpt and truncates 121 characters to 119 plus ellipsis', async () => {
|
it('keeps memo rows free of body text after saves of different content lengths', async () => {
|
||||||
const request = vi.fn(async (path: string): Promise<unknown> => path === '/memos/m1'
|
const request = vi.fn(async (path: string): Promise<unknown> => path === '/memos/m1'
|
||||||
? { ...item, content: '正文' }
|
? { ...item, content: '正文' }
|
||||||
: { items: [item], total: 1 })
|
: { items: [item], total: 1 })
|
||||||
@@ -454,12 +454,14 @@ describe('MemoPanel', () => {
|
|||||||
content.value = 'a'.repeat(120); content.dispatchEvent(new Event('input')); await nextTick()
|
content.value = 'a'.repeat(120); content.dispatchEvent(new Event('input')); await nextTick()
|
||||||
request.mockResolvedValueOnce({ ...item, content: 'a'.repeat(120), version: 2 })
|
request.mockResolvedValueOnce({ ...item, content: 'a'.repeat(120), version: 2 })
|
||||||
host.querySelector<HTMLButtonElement>('.memo-save')!.click(); await flush()
|
host.querySelector<HTMLButtonElement>('.memo-save')!.click(); await flush()
|
||||||
expect(host.querySelector('.memo-row__excerpt')?.textContent).toBe('a'.repeat(120))
|
expect(host.querySelector('.memo-row__excerpt')).toBeNull()
|
||||||
|
expect(host.querySelector('.memo-row')?.textContent).not.toContain('a'.repeat(120))
|
||||||
|
|
||||||
content.value = 'b'.repeat(121); content.dispatchEvent(new Event('input')); await nextTick()
|
content.value = 'b'.repeat(121); content.dispatchEvent(new Event('input')); await nextTick()
|
||||||
request.mockResolvedValueOnce({ ...item, content: 'b'.repeat(121), version: 3 })
|
request.mockResolvedValueOnce({ ...item, content: 'b'.repeat(121), version: 3 })
|
||||||
host.querySelector<HTMLButtonElement>('.memo-save')!.click(); await flush()
|
host.querySelector<HTMLButtonElement>('.memo-save')!.click(); await flush()
|
||||||
expect(host.querySelector('.memo-row__excerpt')?.textContent).toBe(`${'b'.repeat(119)}…`)
|
expect(host.querySelector('.memo-row__excerpt')).toBeNull()
|
||||||
|
expect(host.querySelector('.memo-row')?.textContent).not.toContain('b'.repeat(121))
|
||||||
})
|
})
|
||||||
|
|
||||||
it('updates from the save response, reorders rows, and removes a search mismatch', async () => {
|
it('updates from the save response, reorders rows, and removes a search mismatch', async () => {
|
||||||
@@ -477,7 +479,8 @@ describe('MemoPanel', () => {
|
|||||||
host.querySelector<HTMLButtonElement>('.memo-save')!.click(); await flush()
|
host.querySelector<HTMLButtonElement>('.memo-save')!.click(); await flush()
|
||||||
expect([...host.querySelectorAll('.memo-row strong')].map((node) => node.textContent)).toEqual(['最新', '较旧'])
|
expect([...host.querySelectorAll('.memo-row strong')].map((node) => node.textContent)).toEqual(['最新', '较旧'])
|
||||||
expect(host.querySelector<HTMLInputElement>('[aria-label="备忘录标题"]')?.value).toBe('最新')
|
expect(host.querySelector<HTMLInputElement>('[aria-label="备忘录标题"]')?.value).toBe('最新')
|
||||||
expect(host.querySelector('.memo-row__excerpt')?.textContent).toBe(`${'x'.repeat(119)}…`)
|
expect(host.querySelector('.memo-row__excerpt')).toBeNull()
|
||||||
|
expect(host.querySelector('.memo-row')?.textContent).not.toContain('x'.repeat(121))
|
||||||
|
|
||||||
vi.useFakeTimers()
|
vi.useFakeTimers()
|
||||||
const search = host.querySelector<HTMLInputElement>('[aria-label="搜索备忘录"]')!
|
const search = host.querySelector<HTMLInputElement>('[aria-label="搜索备忘录"]')!
|
||||||
|
|||||||
@@ -125,10 +125,6 @@ async function createMemo() {
|
|||||||
detailOpener = searchInput.value
|
detailOpener = searchInput.value
|
||||||
emit('detail', true)
|
emit('detail', true)
|
||||||
}
|
}
|
||||||
function memoExcerpt(content: string) {
|
|
||||||
const collapsed = content.replace(/\s+/g, ' ').trim()
|
|
||||||
return collapsed.length <= 120 ? collapsed : `${collapsed.slice(0, 119)}…`
|
|
||||||
}
|
|
||||||
function matchesCriteria(memo: MemoRecord, criteria: MemoCriteria) {
|
function matchesCriteria(memo: MemoRecord, criteria: MemoCriteria) {
|
||||||
if (criteria.scope !== 'active') return false
|
if (criteria.scope !== 'active') return false
|
||||||
const needle = criteria.query.toLocaleLowerCase()
|
const needle = criteria.query.toLocaleLowerCase()
|
||||||
@@ -158,7 +154,7 @@ function updateItem(memo: MemoRecord, selectionToken: number) {
|
|||||||
}
|
}
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
const listItem = { ...memo, excerpt: memoExcerpt(memo.content) }
|
const listItem = memo
|
||||||
if (index < 0) {
|
if (index < 0) {
|
||||||
items.value.push(listItem)
|
items.value.push(listItem)
|
||||||
total.value += 1
|
total.value += 1
|
||||||
|
|||||||
@@ -805,7 +805,7 @@ onBeforeUnmount(() => {
|
|||||||
</section>
|
</section>
|
||||||
<AppSheet :open="Boolean(selectedHabit)" variant="detail" panel-class="habit-detail-sheet" title-id="habit-detail-title" initial-focus="button[aria-label='关闭习惯详情']" :busy="busy" @close="closeHabitDetail">
|
<AppSheet :open="Boolean(selectedHabit)" variant="detail" panel-class="habit-detail-sheet" title-id="habit-detail-title" initial-focus="button[aria-label='关闭习惯详情']" :busy="busy" @close="closeHabitDetail">
|
||||||
<template v-if="selectedHabit">
|
<template v-if="selectedHabit">
|
||||||
<header class="app-sheet__header"><div><small>习惯详情</small><h3 id="habit-detail-title">{{ selectedHabit.name }}</h3></div><button type="button" aria-label="关闭习惯详情" @click="closeHabitDetail"><X/></button></header>
|
<header class="app-sheet__header"><div><h3 id="habit-detail-title">{{ selectedHabit.name }}</h3></div><button type="button" aria-label="关闭习惯详情" @click="closeHabitDetail"><X/></button></header>
|
||||||
<div class="app-sheet__body">
|
<div class="app-sheet__body">
|
||||||
<div class="habit-detail-progress"><span>今日进度</span><strong>{{ selectedHabit.archived_at ? '已归档' : habitProgressText(selectedHabit) || (isDone(selectedHabit, todayKey) ? '已完成' : '未完成') }}</strong></div>
|
<div class="habit-detail-progress"><span>今日进度</span><strong>{{ selectedHabit.archived_at ? '已归档' : habitProgressText(selectedHabit) || (isDone(selectedHabit, todayKey) ? '已完成' : '未完成') }}</strong></div>
|
||||||
<section class="habit-history" aria-labelledby="habit-history-title" :aria-busy="habitHistoryLoading">
|
<section class="habit-history" aria-labelledby="habit-history-title" :aria-busy="habitHistoryLoading">
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ const memo = { id: 'm1', title: '旅行清单', excerpt: '护照\n 充电器
|
|||||||
afterEach(() => cleanups.splice(0).forEach((cleanup) => cleanup()))
|
afterEach(() => cleanups.splice(0).forEach((cleanup) => cleanup()))
|
||||||
|
|
||||||
describe('MemoRow', () => {
|
describe('MemoRow', () => {
|
||||||
it('renders a compact selectable row with plain excerpt and semantic update time', async () => {
|
it('renders only the title and semantic update time in a selectable row', async () => {
|
||||||
const host = document.createElement('div'); document.body.append(host)
|
const host = document.createElement('div'); document.body.append(host)
|
||||||
const selected: Array<{ id: string; opener: EventTarget | null }> = []
|
const selected: Array<{ id: string; opener: EventTarget | null }> = []
|
||||||
const app = createApp(() => h(MemoRow, { memo, active: true, onSelect: (id: string, opener: EventTarget | null) => selected.push({ id, opener }) }))
|
const app = createApp(() => h(MemoRow, { memo, active: true, onSelect: (id: string, opener: EventTarget | null) => selected.push({ id, opener }) }))
|
||||||
@@ -16,31 +16,15 @@ describe('MemoRow', () => {
|
|||||||
const button = host.querySelector('button')!
|
const button = host.querySelector('button')!
|
||||||
expect(button.classList.contains('active')).toBe(true)
|
expect(button.classList.contains('active')).toBe(true)
|
||||||
expect(button.getAttribute('aria-current')).toBe('true')
|
expect(button.getAttribute('aria-current')).toBe('true')
|
||||||
expect(host.querySelector('.memo-row__excerpt')?.textContent).toBe('护照 充电器 相机')
|
expect(button.querySelectorAll(':scope > *')).toHaveLength(2)
|
||||||
|
expect(host.querySelector('.memo-row__excerpt')).toBeNull()
|
||||||
|
expect(button.textContent).not.toContain('护照')
|
||||||
expect(host.querySelector('time')?.getAttribute('datetime')).toBe(memo.updated_at)
|
expect(host.querySelector('time')?.getAttribute('datetime')).toBe(memo.updated_at)
|
||||||
button.click()
|
button.click()
|
||||||
expect(selected).toHaveLength(1)
|
expect(selected).toHaveLength(1)
|
||||||
expect(selected[0]).toEqual({ id: 'm1', opener: button })
|
expect(selected[0]).toEqual({ id: 'm1', opener: button })
|
||||||
})
|
})
|
||||||
|
|
||||||
it('updates the excerpt when the memo prop changes and preserves empty and whitespace formatting', async () => {
|
|
||||||
const host = document.createElement('div'); document.body.append(host)
|
|
||||||
const current = ref({ ...memo, excerpt: '' })
|
|
||||||
const app = createApp(() => h(MemoRow, { memo: current.value }))
|
|
||||||
app.mount(host); cleanups.push(() => { app.unmount(); host.remove() }); await nextTick()
|
|
||||||
|
|
||||||
const excerpt = () => host.querySelector('.memo-row__excerpt')?.textContent
|
|
||||||
expect(excerpt()).toBe('暂无正文')
|
|
||||||
|
|
||||||
current.value = { ...current.value, excerpt: '保存后\n 新摘要 正常' }
|
|
||||||
await nextTick()
|
|
||||||
expect(excerpt()).toBe('保存后 新摘要 正常')
|
|
||||||
|
|
||||||
current.value = { ...current.value, excerpt: ' \n\t ' }
|
|
||||||
await nextTick()
|
|
||||||
expect(excerpt()).toBe('暂无正文')
|
|
||||||
})
|
|
||||||
|
|
||||||
it('updates the displayed timestamp when the memo prop changes', async () => {
|
it('updates the displayed timestamp when the memo prop changes', async () => {
|
||||||
const host = document.createElement('div'); document.body.append(host)
|
const host = document.createElement('div'); document.body.append(host)
|
||||||
const current = ref({ ...memo })
|
const current = ref({ ...memo })
|
||||||
|
|||||||
@@ -1,17 +1,15 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed } from 'vue'
|
import { computed } from 'vue'
|
||||||
|
|
||||||
export type MemoListItem = { id: string; title: string; excerpt: string; version: number; created_at: string; updated_at: string; deleted_at: string | null }
|
export type MemoListItem = { id: string; title: string; excerpt?: string; version: number; created_at: string; updated_at: string; deleted_at: string | null }
|
||||||
const props = defineProps<{ memo: MemoListItem; active?: boolean }>()
|
const props = defineProps<{ memo: MemoListItem; active?: boolean }>()
|
||||||
const emit = defineEmits<{ select: [id: string, opener: EventTarget | null] }>()
|
const emit = defineEmits<{ select: [id: string, opener: EventTarget | null] }>()
|
||||||
const excerpt = computed(() => props.memo.excerpt.replace(/\s+/g, ' ').trim())
|
|
||||||
const updated = computed(() => new Intl.DateTimeFormat('zh-CN', { month: 'numeric', day: 'numeric', hour: '2-digit', minute: '2-digit' }).format(new Date(props.memo.updated_at)))
|
const updated = computed(() => new Intl.DateTimeFormat('zh-CN', { month: 'numeric', day: 'numeric', hour: '2-digit', minute: '2-digit' }).format(new Date(props.memo.updated_at)))
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<button class="memo-row" :class="{ active }" :aria-current="active ? 'true' : undefined" @click="emit('select', memo.id, $event.currentTarget)">
|
<button class="memo-row" :class="{ active }" :aria-current="active ? 'true' : undefined" @click="emit('select', memo.id, $event.currentTarget)">
|
||||||
<strong>{{ memo.title }}</strong>
|
<strong>{{ memo.title }}</strong>
|
||||||
<span class="memo-row__excerpt">{{ excerpt || '暂无正文' }}</span>
|
|
||||||
<time :datetime="memo.updated_at">{{ updated }}</time>
|
<time :datetime="memo.updated_at">{{ updated }}</time>
|
||||||
</button>
|
</button>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -1,3 +1,3 @@
|
|||||||
.memo-panel{position:relative;min-height:calc(100vh - 130px)}.shell.memo-detail-open main{padding-right:372px}.memo-panel__main{display:grid;gap:14px}.memo-toolbar{display:flex;align-items:center;justify-content:space-between;gap:12px}.memo-scope{display:flex;gap:4px;padding:3px;border:1px solid var(--border-cream);border-radius:12px;background:var(--surface-raised)}.memo-search-toggle{display:none}.memo-search-panel{flex:0 1 420px;width:min(420px,100%);min-width:320px;display:flex;align-items:center;gap:4px}.memo-search-clear{width:44px;height:44px;flex:0 0 44px;display:grid;place-items:center;border:0;border-radius:10px;background:transparent;color:var(--muted)}.memo-scope button{min-height:44px;display:inline-flex;align-items:center;gap:6px;border:0;border-radius:9px;background:transparent;padding:0 13px}.memo-scope button[aria-selected="true"]{background:var(--accent-soft);color:#b7421e;font-weight:700}.memo-search{height:44px;flex:1 1 auto;width:100%;min-width:0;display:flex;align-items:center;gap:8px;border:1px solid var(--border-cream);border-radius:11px;background:var(--surface-raised);padding:0 12px}.memo-search input{min-width:0;width:100%;border:0;outline:0;background:transparent;box-shadow:none}.memo-list{display:grid;gap:0;border:1px solid var(--border-cream);border-radius:var(--radius-list);background:var(--surface-raised);overflow:hidden}.memo-list.refreshing{opacity:.62}.memo-row{width:100%;min-height:76px;display:grid;grid-template-columns:minmax(0,1fr) auto;gap:4px 12px;border:0;background:var(--surface-raised);padding:12px 15px;text-align:left}.memo-row+.memo-row{border-top:1px solid var(--border-cream)}.memo-row:hover,.memo-row.active{background:#fff7eb}.memo-row strong{min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.memo-row__excerpt{grid-column:1;display:-webkit-box;overflow:hidden;-webkit-line-clamp:2;-webkit-box-orient:vertical;color:var(--text-secondary);font-size:12px;line-height:1.45;white-space:normal}.memo-row time{grid-column:2;grid-row:1/3;align-self:center;color:var(--muted);font-size:11px}.memo-state{min-height:240px;display:grid;place-items:center;align-content:center;gap:9px;color:var(--muted);text-align:center}.memo-state svg{width:30px;height:30px;color:var(--accent)}.memo-load-more{justify-self:center;min-width:132px;min-height:44px}.memo-error,.memo-editor__error{color:var(--danger);background:#fff0ec;border-radius:10px;padding:10px 12px}.memo-editor>.memo-editor{display:contents}.memo-editor{width:350px;position:fixed;z-index:42;right:0;top:0;bottom:0;display:flex;flex-direction:column;border-left:1px solid var(--border-cream);background:var(--surface-raised);box-shadow:var(--shadow-raised)}.memo-editor header,.memo-editor footer{min-height:64px;display:flex;align-items:center;justify-content:space-between;gap:10px;padding:10px 16px;border-bottom:1px solid var(--border-cream)}.memo-editor header span{font-size:12px;font-weight:750;letter-spacing:.06em;color:var(--muted)}.memo-editor header button{width:44px;height:44px;display:grid;place-items:center;border:0;border-radius:10px;background:transparent}.memo-editor__fields{flex:1;min-height:0;overflow-y:auto;overflow-x:hidden;display:grid;align-content:start;gap:14px;padding:18px}.memo-editor__fields label{display:grid;gap:7px;color:var(--text-secondary);font-size:12px;font-weight:700}.memo-editor__fields input,.memo-editor__fields textarea{width:100%;border:1px solid var(--border-cream);border-radius:11px;background:#fff;padding:12px;outline:0}.memo-editor__fields textarea{resize:vertical;line-height:1.65}.memo-editor__fields input:focus,.memo-editor__fields textarea:focus{border-color:var(--accent);box-shadow:0 0 0 3px var(--focus-ring)}.memo-field{display:grid;gap:7px}.memo-field__label{display:flex;align-items:center;justify-content:space-between;gap:10px;color:var(--text-secondary);font-size:12px;font-weight:700}.memo-markdown-field{display:grid;gap:7px;min-width:0}.memo-markdown-editor{min-width:0}.memo-markdown-editor .markdown-toolbar{max-width:100%}.memo-markdown-editor textarea{min-height:250px;resize:vertical}.memo-markdown-preview{min-height:250px;max-height:none;width:100%;overflow-x:hidden}.memo-markdown-preview pre{max-width:100%;overflow-x:auto}.memo-editor footer{border-top:1px solid var(--border-cream);border-bottom:0}.memo-editor footer button{min-height:44px}.memo-editor-scrim{display:none}
|
.memo-panel{position:relative;min-height:calc(100vh - 130px)}.shell.memo-detail-open main{padding-right:372px}.memo-panel__main{display:grid;gap:14px}.memo-toolbar{display:flex;align-items:center;justify-content:space-between;gap:12px}.memo-scope{display:flex;gap:4px;padding:3px;border:1px solid var(--border-cream);border-radius:12px;background:var(--surface-raised)}.memo-search-toggle{display:none}.memo-search-panel{flex:0 1 420px;width:min(420px,100%);min-width:320px;display:flex;align-items:center;gap:4px}.memo-search-clear{width:44px;height:44px;flex:0 0 44px;display:grid;place-items:center;border:0;border-radius:10px;background:transparent;color:var(--muted)}.memo-scope button{min-height:44px;display:inline-flex;align-items:center;gap:6px;border:0;border-radius:9px;background:transparent;padding:0 13px}.memo-scope button[aria-selected="true"]{background:var(--accent-soft);color:#b7421e;font-weight:700}.memo-search{height:44px;flex:1 1 auto;width:100%;min-width:0;display:flex;align-items:center;gap:8px;border:1px solid var(--border-cream);border-radius:11px;background:var(--surface-raised);padding:0 12px}.memo-search input{min-width:0;width:100%;border:0;outline:0;background:transparent;box-shadow:none}.memo-list{display:grid;gap:0;border:1px solid var(--border-cream);border-radius:var(--radius-list);background:var(--surface-raised);overflow:hidden}.memo-list.refreshing{opacity:.62}.memo-row{width:100%;height:74px;min-height:74px;max-height:76px;display:grid;grid-template-columns:minmax(0,1fr) auto;align-items:center;gap:12px;border:0;background:var(--surface-raised);padding:9px 15px;text-align:left}.memo-row+.memo-row{border-top:1px solid var(--border-cream)}.memo-row:hover,.memo-row.active{background:#fff7eb}.memo-row strong{min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.memo-row time{grid-column:2;grid-row:1;align-self:center;color:var(--muted);font-size:11px}.memo-state{min-height:240px;display:grid;place-items:center;align-content:center;gap:9px;color:var(--muted);text-align:center}.memo-state svg{width:30px;height:30px;color:var(--accent)}.memo-load-more{justify-self:center;min-width:132px;min-height:44px}.memo-error,.memo-editor__error{color:var(--danger);background:#fff0ec;border-radius:10px;padding:10px 12px}.memo-editor>.memo-editor{display:contents}.memo-editor{width:350px;position:fixed;z-index:42;right:0;top:0;bottom:0;display:flex;flex-direction:column;border-left:1px solid var(--border-cream);background:var(--surface-raised);box-shadow:var(--shadow-raised)}.memo-editor header,.memo-editor footer{min-height:64px;display:flex;align-items:center;justify-content:space-between;gap:10px;padding:10px 16px;border-bottom:1px solid var(--border-cream)}.memo-editor header span{font-size:12px;font-weight:750;letter-spacing:.06em;color:var(--muted)}.memo-editor header button{width:44px;height:44px;display:grid;place-items:center;border:0;border-radius:10px;background:transparent}.memo-editor__fields{flex:1;min-height:0;overflow-y:auto;overflow-x:hidden;display:grid;align-content:start;gap:14px;padding:18px}.memo-editor__fields label{display:grid;gap:7px;color:var(--text-secondary);font-size:12px;font-weight:700}.memo-editor__fields input,.memo-editor__fields textarea{width:100%;border:1px solid var(--border-cream);border-radius:11px;background:#fff;padding:12px;outline:0}.memo-editor__fields textarea{resize:vertical;line-height:1.65}.memo-editor__fields input:focus,.memo-editor__fields textarea:focus{border-color:var(--accent);box-shadow:0 0 0 3px var(--focus-ring)}.memo-field{display:grid;gap:7px}.memo-field__label{display:flex;align-items:center;justify-content:space-between;gap:10px;color:var(--text-secondary);font-size:12px;font-weight:700}.memo-markdown-field{display:grid;gap:7px;min-width:0}.memo-markdown-editor{min-width:0}.memo-markdown-editor .markdown-toolbar{max-width:100%}.memo-markdown-editor textarea{min-height:250px;resize:vertical}.memo-markdown-preview{min-height:250px;max-height:none;width:100%;overflow-x:hidden}.memo-markdown-preview pre{max-width:100%;overflow-x:auto}.memo-editor footer{border-top:1px solid var(--border-cream);border-bottom:0}.memo-editor footer button{min-height:44px}.memo-editor-scrim{display:none}
|
||||||
@media(max-width:930px){.shell.memo-detail-open main{padding:20px 17px 112px}.memo-panel{min-height:calc(100dvh - 150px)}.memo-toolbar{display:grid;grid-template-columns:minmax(0,1fr) 44px;align-items:center;gap:8px}.memo-scope{min-width:0}.memo-scope button{flex:1;justify-content:center;padding-inline:9px}.memo-search-toggle{width:44px;height:44px;display:grid;place-items:center;border:1px solid var(--border-cream);border-radius:11px;background:var(--surface-raised);color:var(--text-secondary)}.memo-search-panel{grid-column:1/-1;width:100%;min-width:0;max-height:0;opacity:0;overflow:hidden;pointer-events:none;display:flex;transition:max-height .18s ease,opacity .15s ease}.memo-search-panel.is-open{max-height:44px;opacity:1;pointer-events:auto}.memo-search-panel[hidden]{display:none}.memo-search{width:100%;min-width:0}.memo-row{min-height:76px}.memo-editor-scrim{display:block;position:fixed;z-index:41;inset:0;background:var(--scrim)}.memo-editor{position:fixed;z-index:42;left:0;right:0;top:auto;bottom:0;width:100%;max-width:100%;height:min(92dvh,820px);overflow-x:hidden;border:1px solid var(--border-cream);border-bottom:0;border-radius:22px 22px 0 0;transition:transform .22s ease}.memo-editor__fields{padding:16px}.memo-editor footer{padding-bottom:max(10px,env(safe-area-inset-bottom))}}
|
@media(max-width:930px){.shell.memo-detail-open main{padding:20px 17px 112px}.memo-panel{min-height:calc(100dvh - 150px)}.memo-toolbar{display:grid;grid-template-columns:minmax(0,1fr) 44px;align-items:center;gap:8px}.memo-scope{min-width:0}.memo-scope button{flex:1;justify-content:center;padding-inline:9px}.memo-search-toggle{width:44px;height:44px;display:grid;place-items:center;border:1px solid var(--border-cream);border-radius:11px;background:var(--surface-raised);color:var(--text-secondary)}.memo-search-panel{grid-column:1/-1;width:100%;min-width:0;max-height:0;opacity:0;overflow:hidden;pointer-events:none;display:flex;transition:max-height .18s ease,opacity .15s ease}.memo-search-panel.is-open{max-height:44px;opacity:1;pointer-events:auto}.memo-search-panel[hidden]{display:none}.memo-search{width:100%;min-width:0}.memo-row{height:74px;min-height:74px;max-height:76px}.memo-editor-scrim{display:block;position:fixed;z-index:41;inset:0;background:var(--scrim)}.memo-editor{position:fixed;z-index:42;left:0;right:0;top:auto;bottom:0;width:100%;max-width:100%;height:min(92dvh,820px);overflow-x:hidden;border:1px solid var(--border-cream);border-bottom:0;border-radius:22px 22px 0 0;transition:transform .22s ease}.memo-editor__fields{padding:16px}.memo-editor footer{padding-bottom:max(10px,env(safe-area-inset-bottom))}}
|
||||||
@media(prefers-reduced-motion:reduce){.memo-editor,.memo-row,.memo-list{transition:none!important}}
|
@media(prefers-reduced-motion:reduce){.memo-editor,.memo-row,.memo-list{transition:none!important}}
|
||||||
|
|||||||
+20
-1
@@ -36,7 +36,7 @@ main{container-type:inline-size;min-width:0;padding:27px 34px 50px;overflow:auto
|
|||||||
.settings-sections{width:min(100%,760px);margin:0 auto;display:grid;gap:16px}.settings-group{min-width:0;background:var(--surface-raised);border:1px solid var(--border-cream);border-radius:var(--radius-card);box-shadow:var(--highlight-inner),var(--shadow-soft);overflow:hidden}.settings-group>header{padding:16px 18px}.settings-group>header h2{margin:0;font-size:18px}.settings-group>header p{margin:5px 0 0;color:var(--muted);font-size:13px}.settings-row{min-height:56px;padding:6px 18px;border-top:1px solid var(--line);display:flex;align-items:center;justify-content:space-between;gap:14px;min-width:0}.settings-row>span{min-width:0;flex:1;display:grid;gap:2px}.settings-row small,.settings-empty{color:var(--muted);font-size:12px;overflow-wrap:anywhere}.settings-row button,.settings-row .file-button,.settings-row select{min-height:44px;flex:0 0 auto}.settings-form{padding:0 18px 18px}.backup-preflight{margin:0 18px 18px;padding:14px;border:1px solid #bfd3b8;border-radius:11px;background:#f5faf1}.backup-preflight.invalid{background:#fff2ef;border-color:#efc4bc}.restore-replace-warning{color:var(--danger);font-weight:650}.backup-preflight dl{display:flex;flex-wrap:wrap;gap:8px 18px;margin:10px 0}.backup-preflight dl div{display:flex;gap:5px}.backup-preflight dt{color:var(--muted)}.backup-preflight dd{margin:0;font-weight:700}.backup-preflight ul{margin:8px 0;padding-left:20px}.backup-preflight .danger-button{min-height:44px}.settings-empty{margin:0;padding:12px 18px;border-top:1px solid var(--line)}
|
.settings-sections{width:min(100%,760px);margin:0 auto;display:grid;gap:16px}.settings-group{min-width:0;background:var(--surface-raised);border:1px solid var(--border-cream);border-radius:var(--radius-card);box-shadow:var(--highlight-inner),var(--shadow-soft);overflow:hidden}.settings-group>header{padding:16px 18px}.settings-group>header h2{margin:0;font-size:18px}.settings-group>header p{margin:5px 0 0;color:var(--muted);font-size:13px}.settings-row{min-height:56px;padding:6px 18px;border-top:1px solid var(--line);display:flex;align-items:center;justify-content:space-between;gap:14px;min-width:0}.settings-row>span{min-width:0;flex:1;display:grid;gap:2px}.settings-row small,.settings-empty{color:var(--muted);font-size:12px;overflow-wrap:anywhere}.settings-row button,.settings-row .file-button,.settings-row select{min-height:44px;flex:0 0 auto}.settings-form{padding:0 18px 18px}.backup-preflight{margin:0 18px 18px;padding:14px;border:1px solid #bfd3b8;border-radius:11px;background:#f5faf1}.backup-preflight.invalid{background:#fff2ef;border-color:#efc4bc}.restore-replace-warning{color:var(--danger);font-weight:650}.backup-preflight dl{display:flex;flex-wrap:wrap;gap:8px 18px;margin:10px 0}.backup-preflight dl div{display:flex;gap:5px}.backup-preflight dt{color:var(--muted)}.backup-preflight dd{margin:0;font-weight:700}.backup-preflight ul{margin:8px 0;padding-left:20px}.backup-preflight .danger-button{min-height:44px}.settings-empty{margin:0;padding:12px 18px;border-top:1px solid var(--line)}
|
||||||
.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>h2{margin:0;font-size:1.17em}.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}.session-card-actions{width:100%;display:flex;align-items:center;justify-content:space-between;gap:12px}.session-card-actions p{min-width:0}.session-revoke-all{min-height:44px;flex:0 0 auto}.password-form{width:100%;display:grid;gap:10px}.password-form label{display:grid;gap:6px;color:#675f54;font-size:12px;font-weight:650}.password-form input{width:100%;border:1px solid var(--line);background:#fff;border-radius:10px;padding:11px 12px;outline:none}.password-form input:focus{border-color:var(--accent);box-shadow:0 0 0 3px rgba(241,90,41,.1)}.password-form button{justify-self:start}.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;gap:10px;border-top:1px solid var(--line);padding:9px 0}.session-copy{min-width:0;flex:1;display:grid}.session-title{line-height:1.4}.session-meta{min-width:0;display:flex;flex-wrap:wrap;align-items:center;line-height:1.45}.session-device{min-width:0;overflow-wrap:anywhere}.session-revoke{min-width:44px;min-height:44px;flex:0 0 auto;justify-content:center}.audit-copy{min-width:0;display:grid;gap:2px}.audit-action{display:-webkit-box;overflow:hidden;-webkit-box-orient:vertical;-webkit-line-clamp:2;overflow-wrap:anywhere}.session-row small,.audit-row time{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}
|
.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>h2{margin:0;font-size:1.17em}.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}.session-card-actions{width:100%;display:flex;align-items:center;justify-content:space-between;gap:12px}.session-card-actions p{min-width:0}.session-revoke-all{min-height:44px;flex:0 0 auto}.password-form{width:100%;display:grid;gap:10px}.password-form label{display:grid;gap:6px;color:#675f54;font-size:12px;font-weight:650}.password-form input{width:100%;border:1px solid var(--line);background:#fff;border-radius:10px;padding:11px 12px;outline:none}.password-form input:focus{border-color:var(--accent);box-shadow:0 0 0 3px rgba(241,90,41,.1)}.password-form button{justify-self:start}.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;gap:10px;border-top:1px solid var(--line);padding:9px 0}.session-copy{min-width:0;flex:1;display:grid}.session-title{line-height:1.4}.session-meta{min-width:0;display:flex;flex-wrap:wrap;align-items:center;line-height:1.45}.session-device{min-width:0;overflow-wrap:anywhere}.session-revoke{min-width:44px;min-height:44px;flex:0 0 auto;justify-content:center}.audit-copy{min-width:0;display:grid;gap:2px}.audit-action{display:-webkit-box;overflow:hidden;-webkit-box-orient:vertical;-webkit-line-clamp:2;overflow-wrap:anywhere}.session-row small,.audit-row time{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:930px){.task-row,.habit-row,.countdown-row{min-height:62px;background:#fff;border:1px solid var(--line);border-radius:13px;box-shadow:none}.task-list,.habit-list,.countdown-timeline{display:grid;gap:8px}.task-row{padding:4px 7px}.task-row:hover,.task-row.selected{background:#fffaf5}.habit-row{padding:4px 7px}.countdown-row,.countdown-row:first-of-type{border:1px solid var(--line)}.countdown-row{grid-template-columns:minmax(0,1fr) 72px;padding:8px 9px;gap:8px}.task-main strong,.habit-name,.countdown-main>b{font-size:14px;font-weight:650}.meta,.countdown-main>small,.countdown-state small{font-size:11px;color:var(--muted)}.habit-progress{font-size:16px}.task-check{width:44px;flex-basis:44px}.countdown-icon{width:36px;height:36px;border-radius:10px}.countdown-state strong{font-size:24px}.countdown-group{gap:8px}.countdown-group>h3{padding-left:3px}.habit-detail-sheet{width:100%;border-radius:22px 22px 0 0;padding:18px 16px calc(18px + env(safe-area-inset-bottom))}}
|
@media(max-width:930px){.task-row,.habit-row,.countdown-row{min-height:62px;background:#fff;border:1px solid var(--line);border-radius:13px;box-shadow:none}.task-list,.habit-list,.countdown-timeline{display:grid;gap:8px}.task-row{padding:4px 7px}.task-row:hover,.task-row.selected{background:#fffaf5}.habit-row{padding:4px 7px}.countdown-row,.countdown-row:first-of-type{border:1px solid var(--line)}.countdown-row{grid-template-columns:minmax(0,1fr) 72px;padding:8px 9px;gap:8px}.task-main strong,.habit-name,.countdown-main>b{font-size:14px;font-weight:650}.meta,.countdown-main>small,.countdown-state small{font-size:11px;color:var(--muted)}.habit-progress{font-size:16px}.task-check{width:44px;flex-basis:44px}.countdown-icon{width:36px;height:36px;border-radius:10px}.countdown-state strong{font-size:24px}.countdown-group{gap:8px}.countdown-group>h3{padding-left:3px}.habit-detail-sheet{width:100%;border-radius:22px 22px 0 0;padding:18px 16px calc(18px + env(safe-area-inset-bottom))}}
|
||||||
@media(max-width:800px){.settings-grid{grid-template-columns:1fr}.tool-card.wide{grid-column:auto}.habit-main{padding:10px 2px}.numeric-action input{width:62px}}
|
@media(max-width:800px){.settings-grid{grid-template-columns:1fr}.tool-card.wide{grid-column:auto}.habit-main{padding:4px 2px;gap:4px 10px}.habit-week{gap:4px;padding-top:0}.numeric-action input{width:62px}}
|
||||||
@media(max-width:390px){.task-compose-sheet{width:100%;max-width:100%;overflow-x:hidden}.task-compose-sheet .app-sheet__header>button{min-width:44px;min-height:44px}.task-compose-sheet input,.task-compose-sheet select,.task-compose-sheet button{min-height:44px}.task-compose-date-clear,.task-compose-time-remove{min-width:44px;min-height:44px}.task-compose-sheet .app-sheet__footer>button{min-height:44px}.habit-detail-sheet,.habit-compose-sheet{width:100%;max-width:100%}.habit-detail-sheet .app-sheet__header>button,.habit-compose-sheet .app-sheet__header>button{min-width:44px;min-height:44px}.habit-detail-sheet .app-sheet__footer>button,.habit-detail-sheet .app-sheet__danger>button,.habit-compose-sheet .app-sheet__footer>button{min-height:44px}.habit-history__row{grid-template-columns:minmax(0,1fr) auto;gap:4px 10px;padding:7px 0}.habit-history__row time{grid-column:1/-1}.habit-history__row small{grid-column:2;text-align:right}.habit-history__value{grid-column:1;grid-row:2;text-align:left}.habit-history__message{align-items:flex-start;flex-direction:column}}
|
@media(max-width:390px){.task-compose-sheet{width:100%;max-width:100%;overflow-x:hidden}.task-compose-sheet .app-sheet__header>button{min-width:44px;min-height:44px}.task-compose-sheet input,.task-compose-sheet select,.task-compose-sheet button{min-height:44px}.task-compose-date-clear,.task-compose-time-remove{min-width:44px;min-height:44px}.task-compose-sheet .app-sheet__footer>button{min-height:44px}.habit-detail-sheet,.habit-compose-sheet{width:100%;max-width:100%}.habit-detail-sheet .app-sheet__header>button,.habit-compose-sheet .app-sheet__header>button{min-width:44px;min-height:44px}.habit-detail-sheet .app-sheet__footer>button,.habit-detail-sheet .app-sheet__danger>button,.habit-compose-sheet .app-sheet__footer>button{min-height:44px}.habit-history__row{grid-template-columns:minmax(0,1fr) auto;gap:4px 10px;padding:7px 0}.habit-history__row time{grid-column:1/-1}.habit-history__row small{grid-column:2;text-align:right}.habit-history__value{grid-column:1;grid-row:2;text-align:left}.habit-history__message{align-items:flex-start;flex-direction:column}}
|
||||||
.unified-fab{display:grid;place-items:center;position:fixed;z-index:60;right:20px;bottom:22px;width:56px;height:56px;padding:0;border:0;border-radius:50%;background:var(--accent);color:#fff;box-shadow:0 8px 20px rgba(241,90,41,.28);transition:left .2s cubic-bezier(.2,.8,.3,1),top .2s cubic-bezier(.2,.8,.3,1),transform .16s ease,box-shadow .16s ease;touch-action:none;user-select:none}.unified-fab>svg{width:25px;height:25px}.unified-fab:focus-visible{outline:3px solid var(--focus-ring);outline-offset:3px}.unified-fab:hover{transform:translateY(-2px);box-shadow:0 10px 24px rgba(241,90,41,.32)}.unified-fab:active:not(.dragging){transform:scale(.96)}.unified-fab.dragging{transform:scale(1.06);box-shadow:0 12px 28px rgba(241,90,41,.36);transition:transform .16s ease,box-shadow .16s ease}.unified-fab.snapping{box-shadow:0 10px 24px rgba(241,90,41,.32)}.app-overlay{position:fixed;inset:0;z-index:80;display:grid}.app-sheet-mask.app-sheet-mask{background:var(--sheet-scrim);overscroll-behavior:contain}.app-overlay>.detail{transform:none}.app-sheet{min-height:0;overflow:hidden;background:var(--paper)}.app-sheet__header{min-height:64px;flex:0 0 64px;position:sticky;z-index:3;top:0;background:var(--paper);border-bottom:1px solid var(--line);padding:0 18px}.app-sheet__header>div{min-width:0}.app-sheet__header h2,.app-sheet__header h3{overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.app-sheet__header>button{width:44px;height:44px;flex:0 0 44px;display:grid;place-items:center;border:0;background:transparent;border-radius:10px}.app-sheet__body{min-height:0;overflow-y:auto;overscroll-behavior:contain;-webkit-overflow-scrolling:touch;display:grid;gap:14px;padding:16px 18px}.app-sheet__footer{position:sticky;bottom:0;z-index:3;margin:0;background:var(--paper);border-top:1px solid var(--line);padding:12px 18px;padding-bottom:calc(16px + env(safe-area-inset-bottom));display:flex;justify-content:flex-end;gap:8px}.app-sheet__danger{border-top:1px solid #f1d4cd;background:#fff8f6;padding:12px 18px;padding-bottom:calc(16px + env(safe-area-inset-bottom));display:flex;justify-content:flex-end}.app-sheet--actions .app-sheet__body{gap:6px;padding:8px 16px calc(16px + env(safe-area-inset-bottom))}.app-sheet--actions .app-sheet__body>button{min-height:50px;width:100%;display:flex;align-items:center;gap:12px;border:0;background:#fff;padding:13px;border-radius:12px;text-align:left}
|
.unified-fab{display:grid;place-items:center;position:fixed;z-index:60;right:20px;bottom:22px;width:56px;height:56px;padding:0;border:0;border-radius:50%;background:var(--accent);color:#fff;box-shadow:0 8px 20px rgba(241,90,41,.28);transition:left .2s cubic-bezier(.2,.8,.3,1),top .2s cubic-bezier(.2,.8,.3,1),transform .16s ease,box-shadow .16s ease;touch-action:none;user-select:none}.unified-fab>svg{width:25px;height:25px}.unified-fab:focus-visible{outline:3px solid var(--focus-ring);outline-offset:3px}.unified-fab:hover{transform:translateY(-2px);box-shadow:0 10px 24px rgba(241,90,41,.32)}.unified-fab:active:not(.dragging){transform:scale(.96)}.unified-fab.dragging{transform:scale(1.06);box-shadow:0 12px 28px rgba(241,90,41,.36);transition:transform .16s ease,box-shadow .16s ease}.unified-fab.snapping{box-shadow:0 10px 24px rgba(241,90,41,.32)}.app-overlay{position:fixed;inset:0;z-index:80;display:grid}.app-sheet-mask.app-sheet-mask{background:var(--sheet-scrim);overscroll-behavior:contain}.app-overlay>.detail{transform:none}.app-sheet{min-height:0;overflow:hidden;background:var(--paper)}.app-sheet__header{min-height:64px;flex:0 0 64px;position:sticky;z-index:3;top:0;background:var(--paper);border-bottom:1px solid var(--line);padding:0 18px}.app-sheet__header>div{min-width:0}.app-sheet__header h2,.app-sheet__header h3{overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.app-sheet__header>button{width:44px;height:44px;flex:0 0 44px;display:grid;place-items:center;border:0;background:transparent;border-radius:10px}.app-sheet__body{min-height:0;overflow-y:auto;overscroll-behavior:contain;-webkit-overflow-scrolling:touch;display:grid;gap:14px;padding:16px 18px}.app-sheet__footer{position:sticky;bottom:0;z-index:3;margin:0;background:var(--paper);border-top:1px solid var(--line);padding:12px 18px;padding-bottom:calc(16px + env(safe-area-inset-bottom));display:flex;justify-content:flex-end;gap:8px}.app-sheet__danger{border-top:1px solid #f1d4cd;background:#fff8f6;padding:12px 18px;padding-bottom:calc(16px + env(safe-area-inset-bottom));display:flex;justify-content:flex-end}.app-sheet--actions .app-sheet__body{gap:6px;padding:8px 16px calc(16px + env(safe-area-inset-bottom))}.app-sheet--actions .app-sheet__body>button{min-height:50px;width:100%;display:flex;align-items:center;gap:12px;border:0;background:#fff;padding:13px;border-radius:12px;text-align:left}
|
||||||
@media(max-width:930px){.app-sheet{width:100%;max-height:min(88dvh,760px);display:flex!important;flex-direction:column!important;overflow:hidden!important;border-radius:var(--sheet-radius) var(--sheet-radius) 0 0!important;padding:0!important}.app-sheet--detail,.app-sheet--create{max-width:none!important}.app-sheet-mask{padding:0!important;place-items:end center!important;align-items:flex-end!important}.app-sheet__header{display:flex!important;align-items:center!important;justify-content:space-between!important;width:100%}.app-sheet__body{width:100%;flex:1 1 auto}.app-sheet__body label{display:grid;gap:6px}.app-sheet__footer{width:100%;flex:0 0 auto}.app-sheet__footer .primary-small{min-width:124px}.app-sheet__danger{width:100%;flex:0 0 auto}.app-sheet__danger .danger-text{width:100%;min-height:48px;justify-content:center}}
|
@media(max-width:930px){.app-sheet{width:100%;max-height:min(88dvh,760px);display:flex!important;flex-direction:column!important;overflow:hidden!important;border-radius:var(--sheet-radius) var(--sheet-radius) 0 0!important;padding:0!important}.app-sheet--detail,.app-sheet--create{max-width:none!important}.app-sheet-mask{padding:0!important;place-items:end center!important;align-items:flex-end!important}.app-sheet__header{display:flex!important;align-items:center!important;justify-content:space-between!important;width:100%}.app-sheet__body{width:100%;flex:1 1 auto}.app-sheet__body label{display:grid;gap:6px}.app-sheet__footer{width:100%;flex:0 0 auto}.app-sheet__footer .primary-small{min-width:124px}.app-sheet__danger{width:100%;flex:0 0 auto}.app-sheet__danger .danger-text{width:100%;min-height:48px;justify-content:center}}
|
||||||
@@ -85,3 +85,22 @@ input,select,textarea,.search{background:var(--surface-raised);border-color:var(
|
|||||||
:focus-visible{outline:3px solid var(--focus-ring);outline-offset:2px}.task-check:focus-visible,.today-track:focus-visible,.archived-lists-toggle:focus-visible,.archived-row-menu-trigger:focus-visible,.archived-row-actions button:focus-visible{outline:3px solid var(--focus-ring);outline-offset:2px}
|
:focus-visible{outline:3px solid var(--focus-ring);outline-offset:2px}.task-check:focus-visible,.today-track:focus-visible,.archived-lists-toggle:focus-visible,.archived-row-menu-trigger:focus-visible,.archived-row-actions button:focus-visible{outline:3px solid var(--focus-ring);outline-offset:2px}
|
||||||
@media(max-width:930px){.bottom{left:12px;right:12px;bottom:max(10px,env(safe-area-inset-bottom));background:var(--surface-raised);border:1px solid var(--border-cream);border-radius:20px;padding:7px 5px;box-shadow:var(--highlight-inner),var(--shadow-raised)}.bottom button{min-height:44px;border-radius:11px}.bottom button.active{background:#fbe6dc}.sidebar{border-radius:0 var(--radius-panel) var(--radius-panel) 0}.detail,.app-sheet,.task-compose-sheet,.habit-detail-sheet,.countdown-detail-sheet{border-radius:var(--sheet-radius) var(--sheet-radius) 0 0!important}.task-list,.habit-list,.countdown-timeline{display:grid;gap:0}.task-row,.habit-row,.countdown-row{min-height:62px;background:var(--surface-raised);border:0;border-radius:0;box-shadow:none}.task-row+.task-row,.habit-row+.habit-row,.countdown-row+.countdown-row{border-top:1px solid var(--border-cream)}.countdown-row:first-of-type{border-top:0}.unified-fab{bottom:calc(88px + env(safe-area-inset-bottom))}}
|
@media(max-width:930px){.bottom{left:12px;right:12px;bottom:max(10px,env(safe-area-inset-bottom));background:var(--surface-raised);border:1px solid var(--border-cream);border-radius:20px;padding:7px 5px;box-shadow:var(--highlight-inner),var(--shadow-raised)}.bottom button{min-height:44px;border-radius:11px}.bottom button.active{background:#fbe6dc}.sidebar{border-radius:0 var(--radius-panel) var(--radius-panel) 0}.detail,.app-sheet,.task-compose-sheet,.habit-detail-sheet,.countdown-detail-sheet{border-radius:var(--sheet-radius) var(--sheet-radius) 0 0!important}.task-list,.habit-list,.countdown-timeline{display:grid;gap:0}.task-row,.habit-row,.countdown-row{min-height:62px;background:var(--surface-raised);border:0;border-radius:0;box-shadow:none}.task-row+.task-row,.habit-row+.habit-row,.countdown-row+.countdown-row{border-top:1px solid var(--border-cream)}.countdown-row:first-of-type{border-top:0}.unified-fab{bottom:calc(88px + env(safe-area-inset-bottom))}}
|
||||||
@media(prefers-reduced-motion:reduce){*,*:before,*:after{scroll-behavior:auto!important;animation-duration:.01ms!important;transition-duration:.01ms!important}.completed-filter-pill,.completed-filter-pill__track,.completed-filter-pill__thumb{transition:none!important}.topbar-refresh.spinning svg{animation:none!important}.completed-filter-pill:active:not(:disabled){transform:none}.task-row.just-completed,.habit-row.just-completed,.task-row.just-completed .task-check-mark,.habit-row.just-completed .task-check-mark{animation:none}}
|
@media(prefers-reduced-motion:reduce){*,*:before,*:after{scroll-behavior:auto!important;animation-duration:.01ms!important;transition-duration:.01ms!important}.completed-filter-pill,.completed-filter-pill__track,.completed-filter-pill__thumb{transition:none!important}.topbar-refresh.spinning svg{animation:none!important}.completed-filter-pill:active:not(:disabled){transform:none}.task-row.just-completed,.habit-row.just-completed,.task-row.just-completed .task-check-mark,.habit-row.just-completed .task-check-mark{animation:none}}
|
||||||
|
/* Approved five-detail list/search polish. */
|
||||||
|
@media(min-width:931px){.task-search-toggle{display:none}.search-reveal{min-width:320px}}
|
||||||
|
@media(max-width:930px){
|
||||||
|
.topbar{grid-template-columns:44px minmax(0,1fr) auto 44px;grid-template-areas:"menu title filter search" "task-search task-search task-search task-search";row-gap:8px}
|
||||||
|
.search-reveal{grid-area:search;width:44px;min-width:44px;max-height:44px;opacity:1;overflow:visible;visibility:visible;pointer-events:auto;transform:none}
|
||||||
|
.task-search-toggle{width:44px;height:44px;display:grid;place-items:center;border:1px solid var(--border-cream);border-radius:11px;background:var(--surface-raised);color:var(--text-secondary)}
|
||||||
|
.search-reveal>.search{display:none;position:relative;grid-column:1/-1;width:100%;height:44px}
|
||||||
|
.search-reveal.mobile-search-open{grid-area:task-search;width:100%;max-height:44px}
|
||||||
|
.search-reveal.mobile-search-open .task-search-toggle{position:absolute;right:0;top:-52px}
|
||||||
|
.search-reveal.mobile-search-open>.search{display:flex}
|
||||||
|
.search-reveal.mobile-search-pulling{grid-area:task-search;width:100%}
|
||||||
|
.task-row{min-height:66px;height:66px;max-height:68px}
|
||||||
|
.habit-row{min-height:86px;height:86px;max-height:88px}
|
||||||
|
.today-habit-row{min-height:86px;height:86px;max-height:88px}
|
||||||
|
.countdown-focus{height:140px;min-height:140px;max-height:140px;padding:10px 16px;gap:2px}
|
||||||
|
.countdown-focus h3{margin:2px 0 0}
|
||||||
|
.countdown-number{margin:0}
|
||||||
|
}
|
||||||
|
.task-row{min-height:66px;height:66px;max-height:68px}
|
||||||
|
|||||||
@@ -1149,7 +1149,7 @@ describe('unified floating add interaction', () => {
|
|||||||
expect(saveBlock).toContain("const taskId = selectedTask.value?.id")
|
expect(saveBlock).toContain("const taskId = selectedTask.value?.id")
|
||||||
expect(saveBlock).toContain('const selectionToken = recurrenceLoadToken')
|
expect(saveBlock).toContain('const selectionToken = recurrenceLoadToken')
|
||||||
expect(saveBlock).toContain("const repeatValue = selectedDueDate.value ? selectedTaskRepeat.value : 'none'")
|
expect(saveBlock).toContain("const repeatValue = selectedDueDate.value ? selectedTaskRepeat.value : 'none'")
|
||||||
expect(saveBlock).toContain('structuredClone(selectedRepeatConfig.value)')
|
expect(saveBlock).toContain('JSON.parse(JSON.stringify(selectedRepeatConfig.value))')
|
||||||
expect(saveBlock).toContain("const taskSaved = await saveTask({ showSuccess: false, expectedTaskId: taskId, expectedSelectionToken: selectionToken })")
|
expect(saveBlock).toContain("const taskSaved = await saveTask({ showSuccess: false, expectedTaskId: taskId, expectedSelectionToken: selectionToken })")
|
||||||
expect(saveBlock).toContain("recurrenceLoadToken !== selectionToken")
|
expect(saveBlock).toContain("recurrenceLoadToken !== selectionToken")
|
||||||
expect(saveBlock).toContain('if (!taskSaved.due_at) {')
|
expect(saveBlock).toContain('if (!taskSaved.due_at) {')
|
||||||
|
|||||||
@@ -0,0 +1,57 @@
|
|||||||
|
import { readFileSync } from 'node:fs'
|
||||||
|
import { describe, expect, it } from 'vitest'
|
||||||
|
|
||||||
|
const app = readFileSync('src/App.vue', 'utf8')
|
||||||
|
const countdown = readFileSync('src/CountdownPanel.vue', 'utf8')
|
||||||
|
const habits = readFileSync('src/MvpPanel.vue', 'utf8')
|
||||||
|
const css = readFileSync('src/style.css', 'utf8')
|
||||||
|
const memoCss = readFileSync('src/memo.css', 'utf8')
|
||||||
|
const memoPanel = readFileSync('src/MemoPanel.vue', 'utf8')
|
||||||
|
const memoRow = readFileSync('src/components/MemoRow.vue', 'utf8')
|
||||||
|
|
||||||
|
describe('approved five-detail polish', () => {
|
||||||
|
it('uses an independent accessible task-search panel toggle', () => {
|
||||||
|
expect(app).toContain(":aria-label=\"mobileSearchOpen ? '收起搜索任务' : '展开搜索任务'\"")
|
||||||
|
expect(app).toContain('aria-controls="task-search-panel"')
|
||||||
|
expect(app).toContain('@keydown="handleTaskSearchKeydown"')
|
||||||
|
expect(app).not.toContain('mobileSearchOpen || Boolean(query)')
|
||||||
|
expect(css).toMatch(/\.task-search-toggle\{[^}]*width:44px;[^}]*height:44px/)
|
||||||
|
expect(css).toMatch(/@media\(min-width:931px\)\{[^}]*\.task-search-toggle\{display:none\}/)
|
||||||
|
expect(css).toMatch(/@media\(min-width:931px\)\{[\s\S]*?\.search-reveal\{[^}]*min-width:320px/)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('keeps the countdown focus compact and detail titles unique', () => {
|
||||||
|
const focusStart = countdown.indexOf('class="countdown-focus"')
|
||||||
|
const focus = countdown.slice(focusStart, countdown.indexOf('</button>', focusStart))
|
||||||
|
expect(focus).not.toContain('置顶的重要日子')
|
||||||
|
expect(focus).not.toContain('下一个重要日子')
|
||||||
|
expect(focus).not.toContain('countdown-copy')
|
||||||
|
expect(focus).toContain('<h3>{{focusItem.title}}</h3>')
|
||||||
|
expect(countdown).not.toContain('<small>重要日子详情</small>')
|
||||||
|
expect(habits).not.toContain('<small>习惯详情</small>')
|
||||||
|
expect(countdown).toContain('title-id="countdown-detail-title"')
|
||||||
|
expect(habits).toContain('title-id="habit-detail-title"')
|
||||||
|
expect(css).toMatch(/@media\(max-width:930px\)\{[\s\S]*?\.countdown-focus\{[^}]*height:140px/)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('keeps every desktop and mobile task row at the approved height', () => {
|
||||||
|
const polishStart = css.indexOf('/* Approved five-detail list/search polish. */')
|
||||||
|
const polish = css.slice(polishStart)
|
||||||
|
expect(polish).toContain('}\n.task-row{min-height:66px;height:66px;max-height:68px}')
|
||||||
|
expect(polish).toContain('.habit-row{min-height:86px;height:86px;max-height:88px}')
|
||||||
|
expect(polish).toContain('.today-habit-row{min-height:86px;height:86px;max-height:88px}')
|
||||||
|
expect(memoCss).toContain('.memo-row{width:100%;height:74px;min-height:74px;max-height:76px;')
|
||||||
|
expect(css).toContain('.task-check{width:44px;')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('renders memo rows with title and time only at a fixed compact height', () => {
|
||||||
|
expect(memoPanel).not.toContain('memoPlainTextSummary')
|
||||||
|
expect(memoRow).not.toContain('memoPlainTextSummary')
|
||||||
|
expect(memoRow).not.toContain('memo-row__excerpt')
|
||||||
|
expect(memoRow).not.toContain('memo.excerpt')
|
||||||
|
expect(memoRow).toContain('<strong>{{ memo.title }}</strong>')
|
||||||
|
expect(memoRow).toContain('<time :datetime="memo.updated_at">{{ updated }}</time>')
|
||||||
|
expect(memoCss).not.toContain('.memo-row__excerpt')
|
||||||
|
expect(memoCss).toContain('.memo-row time{grid-column:2;grid-row:1;')
|
||||||
|
})
|
||||||
|
})
|
||||||
Reference in New Issue
Block a user