feat: spacing tokens, actionable failure copy, unified focus ring, 44px touch targets, press feedback
ci / gitleaks (push) Successful in 8s
ci / docker (push) Successful in 3m36s

This commit is contained in:
2026-09-23 06:45:51 +08:00
parent 058f353965
commit 629e054823
25 changed files with 251 additions and 181 deletions
+2 -2
View File
@@ -271,8 +271,8 @@ test('settings match the approved paper-ledger geometry and action hierarchy', a
expect(layout.bodyOverflow).toBe(0) expect(layout.bodyOverflow).toBe(0)
const isMobileContract = (await page.viewportSize())!.width <= 720 const isMobileContract = (await page.viewportSize())!.width <= 720
if (isMobileContract) { if (isMobileContract) {
expect(layout.leftPadding).toBeCloseTo(29, 0) expect(layout.leftPadding).toBeCloseTo(28, 0)
expect(layout.rightPadding).toBeCloseTo(29, 0) expect(layout.rightPadding).toBeCloseTo(28, 0)
expect(layout.headingSize).toBe('24px') expect(layout.headingSize).toBe('24px')
} else { } else {
const pageWidth = await page.locator('.settings-sections').evaluate(element => element.getBoundingClientRect().width) const pageWidth = await page.locator('.settings-sections').evaluate(element => element.getBoundingClientRect().width)
+4 -4
View File
@@ -60,7 +60,7 @@ test('Today plain-list layout matches the approved responsive geometry', async (
} }
})).toEqual({ })).toEqual({
media720: width <= 720, media720: width <= 720,
contentWidth: width >= 1440 ? 1080 : width > 930 ? Math.min(1080, width - 324) : width === 721 ? 630 : width - 58, contentWidth: width >= 1440 ? 1080 : width > 930 ? Math.min(1080, width - 324) : width === 721 ? 630 : width - 56,
environmentHeight: width >= 721 ? 55 : width === 375 ? 79 : 81, environmentHeight: width >= 721 ? 55 : width === 375 ? 79 : 81,
}) })
await expect(page.locator('.today-heading')).toHaveCount(1) await expect(page.locator('.today-heading')).toHaveCount(1)
@@ -270,9 +270,9 @@ test('Today plain-list layout matches the approved responsive geometry', async (
expect(metrics.directions.weather).toBe('column') expect(metrics.directions.weather).toBe('column')
expect(metrics.directions.gold).toBe('column') expect(metrics.directions.gold).toBe('column')
if (width <= 720) { if (width <= 720) {
expect(metrics.content.width).toBeCloseTo(width - 58, 0) expect(metrics.content.width).toBeCloseTo(width - 56, 0)
expect(metrics.content.left).toBeCloseTo(29, 0) expect(metrics.content.left).toBeCloseTo(28, 0)
expect(metrics.styles.mainPaddingLeft).toBe('29px') expect(metrics.styles.mainPaddingLeft).toBe('28px')
expect(metrics.styles.titleFontSize).toBe('24px') expect(metrics.styles.titleFontSize).toBe('24px')
expect(metrics.environment.height).toBeCloseTo(width === 375 ? 79 : 81, 0) expect(metrics.environment.height).toBeCloseTo(width === 375 ? 79 : 81, 0)
} }
+2 -2
View File
@@ -78,8 +78,8 @@ test('Upcoming shares the task-list hierarchy while keeping its date scope and n
expect(geometry.section.left).toBeCloseTo(geometry.list.left, 0) expect(geometry.section.left).toBeCloseTo(geometry.list.left, 0)
expect(geometry.header.width).toBeCloseTo(geometry.list.width, 0) expect(geometry.header.width).toBeCloseTo(geometry.list.width, 0)
if (geometry.viewportWidth <= 720) { if (geometry.viewportWidth <= 720) {
expect(geometry.header.left).toBeCloseTo(29, 0) expect(geometry.header.left).toBeCloseTo(28, 0)
expect(geometry.list.right).toBeCloseTo(geometry.viewportWidth - 29, 0) expect(geometry.list.right).toBeCloseTo(geometry.viewportWidth - 28, 0)
} }
const fab = page.getByRole('button', { name: '添加任务' }) const fab = page.getByRole('button', { name: '添加任务' })
Binary file not shown.

Before

Width:  |  Height:  |  Size: 33 KiB

After

Width:  |  Height:  |  Size: 33 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 43 KiB

After

Width:  |  Height:  |  Size: 43 KiB

+7 -6
View File
@@ -28,6 +28,7 @@ import AppDialog, { type AppDialogOptions } from './components/AppDialog.vue'
import { shanghaiDateKey, useTaskDueClock, watchShanghaiDateRollover } from './lib/task-due-clock' import { shanghaiDateKey, useTaskDueClock, watchShanghaiDateRollover } from './lib/task-due-clock'
import { readTodaySectionCollapse, writeTodaySectionCollapse, type TodaySectionCollapse } from './lib/today-section-collapse' import { readTodaySectionCollapse, writeTodaySectionCollapse, type TodaySectionCollapse } from './lib/today-section-collapse'
import { loadTaskOpenTotal } from './lib/task-open-total' import { loadTaskOpenTotal } from './lib/task-open-total'
import { failureHint } from './failureHint'
type FolderItem = { id: string; name: string } type FolderItem = { id: string; name: string }
type TaskList = { id: string; folder_id: string | null; name: string; is_inbox: boolean } type TaskList = { id: string; folder_id: string | null; name: string; is_inbox: boolean }
@@ -470,7 +471,7 @@ async function api(path: string, options: RequestInit = {}) {
...options, ...options,
}) })
if (!response.ok) { if (!response.ok) {
let message = '请求失败' let message = '请求失败,请检查网络后重试'
try { const body = await response.json(); message = formatApiErrorDetail(body.detail) } catch { /* noop */ } try { const body = await response.json(); message = formatApiErrorDetail(body.detail) } catch { /* noop */ }
const requestError = new Error(message) as Error & { status: number } const requestError = new Error(message) as Error & { status: number }
requestError.status = response.status requestError.status = response.status
@@ -483,7 +484,7 @@ function toast(message: string) {
notice.value = message notice.value = message
window.setTimeout(() => { if (notice.value === message) notice.value = '' }, 2400) window.setTimeout(() => { if (notice.value === message) notice.value = '' }, 2400)
} }
function fail(reason: unknown) { error.value = reason instanceof Error ? reason.message : '请求失败' } function fail(reason: unknown) { error.value = failureHint('请求失败', reason) }
async function syncBrowserTimezone(currentTimezone?: string) { async function syncBrowserTimezone(currentTimezone?: string) {
const timezone = Intl.DateTimeFormat().resolvedOptions().timeZone const timezone = Intl.DateTimeFormat().resolvedOptions().timeZone
@@ -1138,7 +1139,7 @@ async function saveSelectedTaskChanges() {
toast('已保存') toast('已保存')
} catch (reason) { } catch (reason) {
if (recurrenceLoadToken !== selectionToken || selectedTask.value?.id !== taskId) return if (recurrenceLoadToken !== selectionToken || selectedTask.value?.id !== taskId) return
selectedRepeatError.value = reason instanceof Error ? reason.message : '保存失败' selectedRepeatError.value = failureHint('保存失败', reason)
fail(reason) fail(reason)
} finally { } finally {
savingSelectedTask.value = false savingSelectedTask.value = false
@@ -1443,7 +1444,7 @@ async function confirmPurgeList() {
focusPurgeListTrigger() focusPurgeListTrigger()
toast('清单已永久删除') toast('清单已永久删除')
} catch (reason) { } catch (reason) {
purgeListError.value = reason instanceof Error ? reason.message : '永久删除失败' purgeListError.value = failureHint('永久删除失败', reason)
} finally { } finally {
purgeListSubmitting.value = false purgeListSubmitting.value = false
} }
@@ -1512,7 +1513,7 @@ async function saveListEditor() {
listEditorFolderId.value = current.folder_id ?? '' listEditorFolderId.value = current.folder_id ?? ''
} }
listEditorError.value = '名称已保存,但移动文件夹失败,请重试。' listEditorError.value = '名称已保存,但移动文件夹失败,请重试。'
} else listEditorError.value = reason instanceof Error ? reason.message : '保存失败' } else listEditorError.value = reason instanceof Error ? reason.message : '保存失败,请检查网络后重试'
} finally { listEditorBusy.value = false } } finally { listEditorBusy.value = false }
} }
async function archiveListFromEditor() { async function archiveListFromEditor() {
@@ -1534,7 +1535,7 @@ async function archiveListFromEditor() {
} }
toast('清单已归档') toast('清单已归档')
} catch (reason) { } catch (reason) {
listEditorError.value = reason instanceof Error ? reason.message : '归档失败' listEditorError.value = failureHint('归档失败', reason)
} finally { listEditorBusy.value = false } } finally { listEditorBusy.value = false }
} }
function toggleFolder(id: string) { const next = new Set(expandedFolders.value); next.has(id) ? next.delete(id) : next.add(id); expandedFolders.value = next } function toggleFolder(id: string) { const next = new Set(expandedFolders.value); next.has(id) ? next.delete(id) : next.add(id); expandedFolders.value = next }
+1 -1
View File
@@ -4,7 +4,7 @@ const app=readFileSync('src/App.vue','utf8');const utils=readFileSync('src/lib/m
describe('calendar shell integration',()=>{ describe('calendar shell integration',()=>{
it('puts calendar beside the other first-level tools on desktop and mobile',()=>{const nav=app.slice(app.indexOf('<nav class="primary-nav">'),app.indexOf('</nav>',app.indexOf('<nav class="primary-nav">')));expect(nav.indexOf("switchView('habits')")).toBeLessThan(nav.indexOf("switchView('countdowns')"));expect(nav.indexOf("switchView('countdowns')")).toBeLessThan(nav.indexOf("switchView('memos')"));expect(nav.indexOf("switchView('memos')")).toBeLessThan(nav.indexOf("switchView('calendar')"));const bottom=app.slice(app.indexOf('<nav class="bottom"'),app.indexOf('</nav>',app.indexOf('<nav class="bottom"')));for(const view of ['today','habits','countdowns','memos','calendar'])expect(bottom).toContain(`switchView('${view}')`);expect(bottom).not.toContain("switchView('settings')")}) it('puts calendar beside the other first-level tools on desktop and mobile',()=>{const nav=app.slice(app.indexOf('<nav class="primary-nav">'),app.indexOf('</nav>',app.indexOf('<nav class="primary-nav">')));expect(nav.indexOf("switchView('habits')")).toBeLessThan(nav.indexOf("switchView('countdowns')"));expect(nav.indexOf("switchView('countdowns')")).toBeLessThan(nav.indexOf("switchView('memos')"));expect(nav.indexOf("switchView('memos')")).toBeLessThan(nav.indexOf("switchView('calendar')"));const bottom=app.slice(app.indexOf('<nav class="bottom"'),app.indexOf('</nav>',app.indexOf('<nav class="bottom"')));for(const view of ['today','habits','countdowns','memos','calendar'])expect(bottom).toContain(`switchView('${view}')`);expect(bottom).not.toContain("switchView('settings')")})
it('persists calendar navigation and mounts the panel',()=>{expect(utils).toContain("'calendar'");expect(app).toContain("import CalendarPanel from './CalendarPanel.vue'");expect(app).toContain("activeView==='calendar'")}) it('persists calendar navigation and mounts the panel',()=>{expect(utils).toContain("'calendar'");expect(app).toContain("import CalendarPanel from './CalendarPanel.vue'");expect(app).toContain("activeView==='calendar'")})
it('places desktop event detail in the shell right pane while retaining the mobile sheet',()=>{const panel=readFileSync('src/CalendarPanel.vue','utf8');expect(app).toContain("'detail-open': Boolean(selectedTask) || habitDetailOpen || calendarDetailOpen");expect(app).toContain(':compact-layout="compactLayout" @detail="calendarDetailOpen=$event"');expect(app).toContain("selectedTask || habitDetailOpen || calendarDetailOpen");expect(app).toContain('watch([selectedTask, habitDetailOpen, calendarDetailOpen, sidebarCollapsed], reconcileDesktopPaneWidths)');expect(panel).toContain('defineProps<{ compactLayout: boolean }>()');expect(panel).toContain(':modal="compactLayout"');expect(panel).toContain('inline-target=".shell"');expect(panel).toContain("watch(selected,event=>emit('detail',Boolean(event)))");expect(panel).toContain("watch(()=>props.compactLayout,()=>{selected.value=null},{flush:'sync'})");expect(panel).not.toContain("onBeforeUnmount(()=>emit('detail',false))");expect(css).toContain('@media(min-width:931px){.calendar-view{padding-top:4px}.calendar-event-detail')}) it('places desktop event detail in the shell right pane while retaining the mobile sheet',()=>{const panel=readFileSync('src/CalendarPanel.vue','utf8');expect(app).toContain("'detail-open': Boolean(selectedTask) || habitDetailOpen || calendarDetailOpen");expect(app).toContain(':compact-layout="compactLayout" @detail="calendarDetailOpen=$event"');expect(app).toContain("selectedTask || habitDetailOpen || calendarDetailOpen");expect(app).toContain('watch([selectedTask, habitDetailOpen, calendarDetailOpen, sidebarCollapsed], reconcileDesktopPaneWidths)');expect(panel).toContain('defineProps<{ compactLayout: boolean }>()');expect(panel).toContain(':modal="compactLayout"');expect(panel).toContain('inline-target=".shell"');expect(panel).toContain("watch(selected,event=>emit('detail',Boolean(event)))");expect(panel).toContain("watch(()=>props.compactLayout,()=>{selected.value=null},{flush:'sync'})");expect(panel).not.toContain("onBeforeUnmount(()=>emit('detail',false))");expect(css).toContain('@media(min-width:931px){.calendar-view{padding-top:var(--space-4)}.calendar-event-detail')})
it('keeps five 44px mobile targets across required breakpoints',()=>{const shellCss=readFileSync('src/style.css','utf8');expect(shellCss).toContain('grid-template-columns:repeat(5,minmax(0,1fr))');expect(css).toContain('min-height:44px');expect(css).toContain('@media(max-width:720px)');expect(css).toContain('@media(min-width:931px)');expect(css).toContain('@media(min-width:1440px)')}) it('keeps five 44px mobile targets across required breakpoints',()=>{const shellCss=readFileSync('src/style.css','utf8');expect(shellCss).toContain('grid-template-columns:repeat(5,minmax(0,1fr))');expect(css).toContain('min-height:44px');expect(css).toContain('@media(max-width:720px)');expect(css).toContain('@media(min-width:931px)');expect(css).toContain('@media(min-width:1440px)')})
it('uses the shell page title once and contains the horizontal source scroller',()=>{const panel=readFileSync('src/CalendarPanel.vue','utf8');expect(panel).not.toContain('<h1>日历订阅</h1>');expect(css).toContain('.calendar-view{min-width:0;');expect(css).toContain('.calendar-filters{min-width:0;max-width:100%;')}) it('uses the shell page title once and contains the horizontal source scroller',()=>{const panel=readFileSync('src/CalendarPanel.vue','utf8');expect(panel).not.toContain('<h1>日历订阅</h1>');expect(css).toContain('.calendar-view{min-width:0;');expect(css).toContain('.calendar-filters{min-width:0;max-width:100%;')})
it('uses the approved minimal reading layout for event details',()=>{const panel=readFileSync('src/CalendarPanel.vue','utf8');expect(panel).toContain('class="calendar-event-detail__meta"');expect(panel).toContain('class="calendar-event-detail__content"');expect(panel).not.toContain('<dt>时间</dt>');expect(css).toContain('.calendar-event-detail__meta{');expect(css).toContain('.calendar-event-detail__content{')}) it('uses the approved minimal reading layout for event details',()=>{const panel=readFileSync('src/CalendarPanel.vue','utf8');expect(panel).toContain('class="calendar-event-detail__meta"');expect(panel).toContain('class="calendar-event-detail__content"');expect(panel).not.toContain('<dt>时间</dt>');expect(css).toContain('.calendar-event-detail__meta{');expect(css).toContain('.calendar-event-detail__content{')})
+8 -7
View File
@@ -5,6 +5,7 @@ import { csrfHeader } from './lib/csrf'
import { formatApiErrorDetail } from './lib/mvp-utils' import { formatApiErrorDetail } from './lib/mvp-utils'
import AppSheet from './components/AppSheet.vue' import AppSheet from './components/AppSheet.vue'
import AppDialog, { type AppDialogOptions } from './components/AppDialog.vue' import AppDialog, { type AppDialogOptions } from './components/AppDialog.vue'
import { failureHint } from './failureHint'
type Subscription = { id:string; name:string; url:string; color:string; enabled:boolean; refreshed_at:string|null; last_error:string|null; stale:boolean } type Subscription = { id:string; name:string; url:string; color:string; enabled:boolean; refreshed_at:string|null; last_error:string|null; stale:boolean }
type CalendarEvent = { id:string; title:string; starts_at:string; ends_at:string; all_day:boolean; source_id:string; source_name:string; color:string; description?:string|null; location?:string|null } type CalendarEvent = { id:string; title:string; starts_at:string; ends_at:string; all_day:boolean; source_id:string; source_name:string; color:string; description?:string|null; location?:string|null }
@@ -43,17 +44,17 @@ function displayDay(day:string){const [y,m,d]=day.split('-').map(Number);return
function displayTime(event:CalendarEvent){if(event.all_day)return'全天';const date=new Date(eventStart(event));return Number.isNaN(date.getTime())?'时间待定':new Intl.DateTimeFormat('zh-CN',{hour:'2-digit',minute:'2-digit'}).format(date)} function displayTime(event:CalendarEvent){if(event.all_day)return'全天';const date=new Date(eventStart(event));return Number.isNaN(date.getTime())?'时间待定':new Intl.DateTimeFormat('zh-CN',{hour:'2-digit',minute:'2-digit'}).format(date)}
async function loadSubscriptions(){subscriptions.value=await request('/calendar-subscriptions') as Subscription[]} async function loadSubscriptions(){subscriptions.value=await request('/calendar-subscriptions') as Subscription[]}
async function loadEvents(){const generation=++eventsRequestGeneration;const requestedRange=range.value;const data=await request(`/calendar-events?start=${encodeURIComponent(requestedRange.start)}&end=${encodeURIComponent(requestedRange.end)}`) as EventResponse;if(generation===eventsRequestGeneration)events.value=data.events} async function loadEvents(){const generation=++eventsRequestGeneration;const requestedRange=range.value;const data=await request(`/calendar-events?start=${encodeURIComponent(requestedRange.start)}&end=${encodeURIComponent(requestedRange.end)}`) as EventResponse;if(generation===eventsRequestGeneration)events.value=data.events}
async function load(){loading.value=true;error.value='';try{await Promise.all([loadSubscriptions(),loadEvents()])}catch(reason){error.value=reason instanceof Error?reason.message:'日历载入失败'}finally{loading.value=false}} async function load(){loading.value=true;error.value='';try{await Promise.all([loadSubscriptions(),loadEvents()])}catch(reason){error.value=failureHint('日历载入失败', reason)}finally{loading.value=false}}
async function moveWeek(offset:number){const next=new Date(weekStart.value);next.setDate(next.getDate()+offset*7);selectedDay.value=next;await loadEvents().catch(reason=>error.value=reason instanceof Error?reason.message:'事件载入失败')} async function moveWeek(offset:number){const next=new Date(weekStart.value);next.setDate(next.getDate()+offset*7);selectedDay.value=next;await loadEvents().catch(reason=>error.value=failureHint('事件载入失败', reason))}
async function today(){const now=new Date();selectedDay.value=new Date(now.getFullYear(),now.getMonth(),now.getDate());await loadEvents().catch(reason=>error.value=reason instanceof Error?reason.message:'事件载入失败')} async function today(){const now=new Date();selectedDay.value=new Date(now.getFullYear(),now.getMonth(),now.getDate());await loadEvents().catch(reason=>error.value=reason instanceof Error?reason.message:'事件载入失败,请检查网络后重试')}
function selectDay(date:Date){selectedDay.value=new Date(date.getFullYear(),date.getMonth(),date.getDate())} function selectDay(date:Date){selectedDay.value=new Date(date.getFullYear(),date.getMonth(),date.getDate())}
function toggleFilter(id:string){const next=new Set(hiddenSources.value);next.has(id)?next.delete(id):next.add(id);hiddenSources.value=next} function toggleFilter(id:string){const next=new Set(hiddenSources.value);next.has(id)?next.delete(id):next.add(id);hiddenSources.value=next}
function openCreate(){editing.value=null;form.value={name:'',url:'',color:'#f15a29',enabled:true};formOpen.value=true} function openCreate(){editing.value=null;form.value={name:'',url:'',color:'#f15a29',enabled:true};formOpen.value=true}
function openEdit(item:Subscription){editing.value=item;form.value={name:item.name,url:item.url,color:item.color||'#f15a29',enabled:item.enabled};formOpen.value=true} function openEdit(item:Subscription){editing.value=item;form.value={name:item.name,url:item.url,color:item.color||'#f15a29',enabled:item.enabled};formOpen.value=true}
async function save(){if(busyId.value||!form.value.name.trim()||!form.value.url.trim())return;busyId.value='form';error.value='';try{await request(editing.value?`/calendar-subscriptions/${editing.value.id}`:'/calendar-subscriptions',{method:editing.value?'PATCH':'POST',body:JSON.stringify({...form.value,name:form.value.name.trim(),url:form.value.url.trim()})});formOpen.value=false;await Promise.all([loadSubscriptions(),loadEvents()]);emit('notice',editing.value?'日历订阅已更新':'日历订阅已添加')}catch(reason){error.value=reason instanceof Error?reason.message:'保存失败'}finally{busyId.value=''}} async function save(){if(busyId.value||!form.value.name.trim()||!form.value.url.trim())return;busyId.value='form';error.value='';try{await request(editing.value?`/calendar-subscriptions/${editing.value.id}`:'/calendar-subscriptions',{method:editing.value?'PATCH':'POST',body:JSON.stringify({...form.value,name:form.value.name.trim(),url:form.value.url.trim()})});formOpen.value=false;await Promise.all([loadSubscriptions(),loadEvents()]);emit('notice',editing.value?'日历订阅已更新':'日历订阅已添加')}catch(reason){error.value=failureHint('保存失败', reason)}finally{busyId.value=''}}
async function toggleEnabled(item:Subscription){if(busyId.value)return;busyId.value=item.id;error.value='';try{await request(`/calendar-subscriptions/${item.id}`,{method:'PATCH',body:JSON.stringify({enabled:!item.enabled})});await Promise.all([loadSubscriptions(),loadEvents()]);emit('notice',item.enabled?'日历订阅已停用':'日历订阅已启用')}catch(reason){error.value=reason instanceof Error?reason.message:'更新失败'}finally{busyId.value=''}} async function toggleEnabled(item:Subscription){if(busyId.value)return;busyId.value=item.id;error.value='';try{await request(`/calendar-subscriptions/${item.id}`,{method:'PATCH',body:JSON.stringify({enabled:!item.enabled})});await Promise.all([loadSubscriptions(),loadEvents()]);emit('notice',item.enabled?'日历订阅已停用':'日历订阅已启用')}catch(reason){error.value=failureHint('更新失败', reason)}finally{busyId.value=''}}
async function refresh(item:Subscription){if(busyId.value)return;busyId.value=item.id;error.value='';try{await request(`/calendar-subscriptions/${item.id}/refresh`,{method:'POST'});await Promise.all([loadSubscriptions(),loadEvents()]);emit('notice',`${item.name}已刷新`)}catch(reason){error.value=reason instanceof Error?reason.message:'刷新失败'}finally{busyId.value=''}} async function refresh(item:Subscription){if(busyId.value)return;busyId.value=item.id;error.value='';try{await request(`/calendar-subscriptions/${item.id}/refresh`,{method:'POST'});await Promise.all([loadSubscriptions(),loadEvents()]);emit('notice',`${item.name}已刷新`)}catch(reason){error.value=failureHint('刷新失败', reason)}finally{busyId.value=''}}
async function remove(item:Subscription){if(busyId.value)return;if(await appDialog.value?.show({title:`删除“${item.name}”?`,description:'该来源的事件也会从日历中移除。',danger:true,confirmText:'删除'})!==true)return;busyId.value=item.id;error.value='';try{await request(`/calendar-subscriptions/${item.id}`,{method:'DELETE'});hiddenSources.value.delete(item.id);await Promise.all([loadSubscriptions(),loadEvents()]);emit('notice','日历订阅已删除')}catch(reason){error.value=reason instanceof Error?reason.message:'删除失败'}finally{busyId.value=''}} async function remove(item:Subscription){if(busyId.value)return;if(await appDialog.value?.show({title:`删除“${item.name}”?`,description:'该来源的事件也会从日历中移除。',danger:true,confirmText:'删除'})!==true)return;busyId.value=item.id;error.value='';try{await request(`/calendar-subscriptions/${item.id}`,{method:'DELETE'});hiddenSources.value.delete(item.id);await Promise.all([loadSubscriptions(),loadEvents()]);emit('notice','日历订阅已删除')}catch(reason){error.value=failureHint('删除失败', reason)}finally{busyId.value=''}}
watch(manageOpen,open=>{if(!open)formOpen.value=false}) watch(manageOpen,open=>{if(!open)formOpen.value=false})
watch(selected,event=>emit('detail',Boolean(event))) watch(selected,event=>emit('detail',Boolean(event)))
watch(()=>props.compactLayout,()=>{selected.value=null},{flush:'sync'}) watch(()=>props.compactLayout,()=>{selected.value=null},{flush:'sync'})
+3 -2
View File
@@ -5,6 +5,7 @@ import { csrfHeader } from './lib/csrf'
import { calendarModeLabel, countdownDayText, countdownKindLabel, dateKey, formatApiErrorDetail, getCountdownCacheGeneration, invalidateCountdownCache, isCountdownCacheGenerationCurrent, loadCountdownCache, readCountdownCache } from './lib/mvp-utils' import { calendarModeLabel, countdownDayText, countdownKindLabel, dateKey, formatApiErrorDetail, getCountdownCacheGeneration, invalidateCountdownCache, isCountdownCacheGenerationCurrent, loadCountdownCache, readCountdownCache } from './lib/mvp-utils'
import AppSheet from './components/AppSheet.vue' import AppSheet from './components/AppSheet.vue'
import AppDialog, { type AppDialogOptions } from './components/AppDialog.vue' import AppDialog, { type AppDialogOptions } from './components/AppDialog.vue'
import { failureHint } from './failureHint'
type Countdown = { type Countdown = {
id: string; title: string; event_date: string; display_date: string; kind: 'countdown'|'anniversary'|'birthday' id: string; title: string; event_date: string; display_date: string; kind: 'countdown'|'anniversary'|'birthday'
@@ -98,7 +99,7 @@ async function safe(detailId:string|null, work:(context:OperationContext)=>Promi
await work(context) await work(context)
} catch(reason) { } catch(reason) {
if (!currentContext(context)) return if (!currentContext(context)) return
error.value=reason instanceof Error ? reason.message : '请求失败' error.value=failureHint('请求失败', reason)
} finally { } finally {
if (activeOperation === context) activeOperation=null if (activeOperation === context) activeOperation=null
if (currentContext(context)) busy.value=false if (currentContext(context)) busy.value=false
@@ -123,7 +124,7 @@ async function load(force = false, manageBusy = true) {
items.value=data.items items.value=data.items
archived.value=data.archived archived.value=data.archived
} catch(reason) { } catch(reason) {
if (manageBusy && isCountdownCacheGenerationCurrent(generation)) error.value=reason instanceof Error ? reason.message : '请求失败' if (manageBusy && isCountdownCacheGenerationCurrent(generation)) error.value=reason instanceof Error ? reason.message : failureHint('请求失败', reason)
} finally { } finally {
if (manageBusy && isCountdownCacheGenerationCurrent(generation)) busy.value=false if (manageBusy && isCountdownCacheGenerationCurrent(generation)) busy.value=false
} }
+1 -1
View File
@@ -64,7 +64,7 @@ describe('memo shell integration', () => {
expect(app).toContain(':show="showFloatingAdd"') expect(app).toContain(':show="showFloatingAdd"')
expect(css).toContain('.shell.memo-detail-open main{padding-right:372px}') expect(css).toContain('.shell.memo-detail-open main{padding-right:372px}')
expect(css).toContain('.memo-editor__fields{flex:1;min-height:0;overflow-y:auto;overflow-x:hidden') expect(css).toContain('.memo-editor__fields{flex:1;min-height:0;overflow-y:auto;overflow-x:hidden')
expect(css).toContain('@media(max-width:930px){.shell.memo-detail-open main{padding:20px 17px 112px}') expect(css).toContain('@media(max-width:930px){.shell.memo-detail-open main{padding:var(--space-20) var(--space-16) 112px}')
expect(css).toContain('.memo-editor{position:fixed;z-index:42;left:0;right:0;top:auto;bottom:0;width:100%;max-width:100%') expect(css).toContain('.memo-editor{position:fixed;z-index:42;left:0;right:0;top:auto;bottom:0;width:100%;max-width:100%')
}) })
+4 -3
View File
@@ -5,6 +5,7 @@ import MemoRow, { type MemoListItem } from './components/MemoRow.vue'
import MemoEditor, { type MemoEditorValue, type MemoRecord } from './components/MemoEditor.vue' import MemoEditor, { type MemoEditorValue, type MemoRecord } from './components/MemoEditor.vue'
import AppDialog, { type AppDialogOptions } from './components/AppDialog.vue' import AppDialog, { type AppDialogOptions } from './components/AppDialog.vue'
import AppSheet from './components/AppSheet.vue' import AppSheet from './components/AppSheet.vue'
import { failureHint } from './failureHint'
type RequestFn = (path: string, options?: RequestInit) => Promise<unknown> type RequestFn = (path: string, options?: RequestInit) => Promise<unknown>
const props = defineProps<{ request: RequestFn }>() const props = defineProps<{ request: RequestFn }>()
@@ -55,7 +56,7 @@ async function load(reset = true) {
committedGeneration = token committedGeneration = token
} }
} catch (reason) { } catch (reason) {
if (token === generation && criteriaEqual(criteria, currentCriteria())) error.value = reason instanceof Error ? reason.message : '加载失败' if (token === generation && criteriaEqual(criteria, currentCriteria())) error.value = failureHint('加载失败', reason)
} finally { } finally {
if (token === generation && criteriaEqual(criteria, currentCriteria())) { if (token === generation && criteriaEqual(criteria, currentCriteria())) {
loading.value = false loading.value = false
@@ -76,7 +77,7 @@ async function loadMore() {
items.value = [...items.value, ...data.items] items.value = [...items.value, ...data.items]
total.value = data.total total.value = data.total
page.value = nextPage page.value = nextPage
} catch (reason) { if (token === generation) error.value = reason instanceof Error ? reason.message : '加载失败' } } catch (reason) { if (token === generation) error.value = reason instanceof Error ? reason.message : failureHint('加载失败', reason) }
finally { if (token === generation) refreshing.value = false } finally { if (token === generation) refreshing.value = false }
} }
function closeDetail() { function closeDetail() {
@@ -108,7 +109,7 @@ async function selectMemo(id: string, opener?: EventTarget | null) {
const memo = await props.request(`/memos/${id}`) as MemoRecord const memo = await props.request(`/memos/${id}`) as MemoRecord
if (token === detailGeneration) { selectedToken.value = token; selected.value = memo; emit('detail', true) } if (token === detailGeneration) { selectedToken.value = token; selected.value = memo; emit('detail', true) }
} catch (reason) { } catch (reason) {
if (token === detailGeneration) error.value = reason instanceof Error ? reason.message : '读取失败' if (token === detailGeneration) error.value = failureHint('读取失败', reason)
} }
} }
async function createMemo() { async function createMemo() {
+16 -15
View File
@@ -10,6 +10,7 @@ import { backupFileSnapshot, isCurrentBackupSnapshot, isLegacyBackup, shouldComm
import AppSheet from './components/AppSheet.vue' import AppSheet from './components/AppSheet.vue'
import AppDialog, { type AppDialogOptions } from './components/AppDialog.vue' import AppDialog, { type AppDialogOptions } from './components/AppDialog.vue'
import CompletedFilterPill from './components/CompletedFilterPill.vue' import CompletedFilterPill from './components/CompletedFilterPill.vue'
import { failureHint } from './failureHint'
type View = 'habits' | 'today-habits' | 'settings' type View = 'habits' | 'today-habits' | 'settings'
type HabitCell = { day: string; scheduled?: boolean; paused?: boolean; value: number | boolean } type HabitCell = { day: string; scheduled?: boolean; paused?: boolean; value: number | boolean }
@@ -158,7 +159,7 @@ async function request<T = unknown>(path: string, options: RequestInit = {}): Pr
} }
async function safe(work: () => Promise<void>) { async function safe(work: () => Promise<void>) {
busy.value = true; error.value = '' busy.value = true; error.value = ''
try { await work() } catch (e) { error.value = e instanceof Error ? e.message : '请求失败' } finally { busy.value = false } try { await work() } catch (e) { error.value = failureHint('请求失败', e) } finally { busy.value = false }
} }
function logFor(h: Habit, day: string) { return (h.cells ?? []).find((c) => c.day === day) } function logFor(h: Habit, day: string) { return (h.cells ?? []).find((c) => c.day === day) }
@@ -203,7 +204,7 @@ async function finishHabitReorder(h: Habit, event: PointerEvent) {
} catch (e) { } catch (e) {
habits.value = previous habits.value = previous
writeHabitGridCache(dateKey(new Date()), previous) writeHabitGridCache(dateKey(new Date()), previous)
error.value = e instanceof Error ? e.message : '请求失败' error.value = e instanceof Error ? e.message : failureHint('请求失败', e)
} }
} }
function cancelHabitReorder() { function cancelHabitReorder() {
@@ -309,7 +310,7 @@ async function mutateHabitValue(h: Habit, next: number | boolean, previous: numb
if (!ownership.current()) return if (!ownership.current()) return
setHabitCompletionExiting(h.id, false) setHabitCompletionExiting(h.id, false)
setLocalHabitValue(h, rollback, day) setLocalHabitValue(h, rollback, day)
error.value = reason instanceof Error ? reason.message : '请求失败' error.value = reason instanceof Error ? reason.message : '请求失败,请检查网络后重试'
}, },
}, },
) )
@@ -424,7 +425,7 @@ async function saveHabit() {
originalHabitForm.value = null originalHabitForm.value = null
await loadHabits() await loadHabits()
} catch (reason) { } catch (reason) {
habitFormError.value = reason instanceof Error ? reason.message : '请求失败' habitFormError.value = reason instanceof Error ? reason.message : failureHint('请求失败', reason)
} finally { } finally {
busy.value = false busy.value = false
} }
@@ -520,7 +521,7 @@ async function loadHabitHistory(reset = false) {
habitHistoryHasMore.value = !habit.start_date || nextTo >= habit.start_date habitHistoryHasMore.value = !habit.start_date || nextTo >= habit.start_date
} catch (reason) { } catch (reason) {
if (requestId !== habitHistoryRequest || selectedHabit.value?.id !== habit.id) return if (requestId !== habitHistoryRequest || selectedHabit.value?.id !== habit.id) return
habitHistoryError.value = reason instanceof Error ? reason.message : '历史记录加载失败' habitHistoryError.value = failureHint('历史记录加载失败', reason)
} finally { } finally {
if (requestId === habitHistoryRequest && selectedHabit.value?.id === habit.id) { if (requestId === habitHistoryRequest && selectedHabit.value?.id === habit.id) {
habitHistoryLoading.value = false habitHistoryLoading.value = false
@@ -563,7 +564,7 @@ async function archiveHabit(h: Habit) {
if (props.view === 'habits' && showArchivedHabits.value) await loadArchivedHabits() if (props.view === 'habits' && showArchivedHabits.value) await loadArchivedHabits()
emit('notice', '习惯已归档') emit('notice', '习惯已归档')
} catch (reason) { } catch (reason) {
error.value = reason instanceof Error ? reason.message : '请求失败' error.value = reason instanceof Error ? reason.message : failureHint('请求失败', reason)
} finally { } finally {
busy.value = false busy.value = false
} }
@@ -585,7 +586,7 @@ async function restoreHabit(h: Habit) {
emit('notice', result.refreshed ? '习惯已恢复' : '习惯已恢复,但列表刷新失败,请重试') emit('notice', result.refreshed ? '习惯已恢复' : '习惯已恢复,但列表刷新失败,请重试')
void nextTick(() => habitArchiveToggle.value?.focus()) void nextTick(() => habitArchiveToggle.value?.focus())
} catch (reason) { } catch (reason) {
error.value = reason instanceof Error ? reason.message : '恢复失败' error.value = failureHint('恢复失败', reason)
} finally { } finally {
busy.value = false busy.value = false
} }
@@ -603,7 +604,7 @@ async function deleteHabit(h: Habit) {
emit('notice', '习惯已永久删除') emit('notice', '习惯已永久删除')
void nextTick(() => habitArchiveToggle.value?.focus()) void nextTick(() => habitArchiveToggle.value?.focus())
} catch (reason) { } catch (reason) {
error.value = reason instanceof Error ? reason.message : '永久删除失败' error.value = failureHint('永久删除失败', reason)
} finally { } finally {
busy.value = false busy.value = false
} }
@@ -617,7 +618,7 @@ async function loadArchivedHabits() {
archiveState.value = 'success' archiveState.value = 'success'
} catch (reason) { } catch (reason) {
archiveState.value = 'error' archiveState.value = 'error'
archiveError.value = reason instanceof Error ? reason.message : '归档习惯加载失败' archiveError.value = failureHint('归档习惯加载失败', reason)
} }
} }
async function toggleArchivedHabits() { async function toggleArchivedHabits() {
@@ -645,7 +646,7 @@ async function loadHabits() {
writeHabitGridCache(week, habits.value) writeHabitGridCache(week, habits.value)
return true return true
} catch (e) { } catch (e) {
error.value = e instanceof Error ? e.message : '请求失败' error.value = e instanceof Error ? e.message : failureHint('请求失败', e)
return false return false
} finally { } finally {
if (!cached) busy.value = false if (!cached) busy.value = false
@@ -667,14 +668,14 @@ async function loadSettings() {
sessionsState.value = 'success' sessionsState.value = 'success'
} else { } else {
sessionsState.value = 'error' sessionsState.value = 'error'
sessionsError.value = sessionResult.reason instanceof Error ? sessionResult.reason.message : '登录设备加载失败' sessionsError.value = sessionResult.reason instanceof Error ? sessionResult.reason.message : '登录设备加载失败,请检查网络后重试'
} }
if (auditResult.status === 'fulfilled') { if (auditResult.status === 'fulfilled') {
audit.value = mergePage<any>(auditResult.value).items audit.value = mergePage<any>(auditResult.value).items
auditState.value = 'success' auditState.value = 'success'
} else { } else {
auditState.value = 'error' auditState.value = 'error'
auditError.value = auditResult.reason instanceof Error ? auditResult.reason.message : '活动记录加载失败' auditError.value = auditResult.reason instanceof Error ? auditResult.reason.message : '活动记录加载失败,请检查网络后重试'
} }
busy.value = false busy.value = false
} }
@@ -695,7 +696,7 @@ function downloadBlob(blob: Blob, name: string) {
async function exportData() { async function exportData() {
backupBusy.value = true; backupError.value = '' backupBusy.value = true; backupError.value = ''
try { downloadBlob(await downloadFullBackup(), 'dodo-backup-v2.zip') } try { downloadBlob(await downloadFullBackup(), 'dodo-backup-v2.zip') }
catch (reason) { backupError.value = reason instanceof Error ? reason.message : '完整备份导出失败' } catch (reason) { backupError.value = failureHint('完整备份导出失败', reason) }
finally { backupBusy.value = false } finally { backupBusy.value = false }
} }
function openRestoreFilePicker() { function openRestoreFilePicker() {
@@ -732,7 +733,7 @@ async function runPreflight() {
acceptedPreflightSnapshot = snapshot acceptedPreflightSnapshot = snapshot
} catch (reason) { } catch (reason) {
if (generation !== preflightGeneration || controller.signal.aborted) return if (generation !== preflightGeneration || controller.signal.aborted) return
backupError.value = reason instanceof Error ? reason.message : '备份预检失败' backupError.value = failureHint('备份预检失败', reason)
} finally { } finally {
if (generation === preflightGeneration) { if (generation === preflightGeneration) {
backupBusy.value = false backupBusy.value = false
@@ -762,7 +763,7 @@ async function restore() {
restoreFile.value = null; restorePreflight.value = null; acceptedPreflightSnapshot = null restoreFile.value = null; restorePreflight.value = null; acceptedPreflightSnapshot = null
if (restoreInput.value) restoreInput.value.value = '' if (restoreInput.value) restoreInput.value.value = ''
emit('changed'); emit('notice', '数据已恢复') emit('changed'); emit('notice', '数据已恢复')
} catch (reason) { backupError.value = reason instanceof Error ? reason.message : '恢复失败' } } catch (reason) { backupError.value = reason instanceof Error ? reason.message : failureHint('恢复失败', reason) }
finally { backupBusy.value = false } finally { backupBusy.value = false }
} }
async function logout() { async function logout() {
+3 -3
View File
@@ -65,10 +65,10 @@ describe('Today environment integration', () => {
expect(css).toContain('main.today-main{padding-left:max(44px,calc((100% - 1080px)/2));padding-right:max(44px,calc((100% - 1080px)/2))}') expect(css).toContain('main.today-main{padding-left:max(44px,calc((100% - 1080px)/2));padding-right:max(44px,calc((100% - 1080px)/2))}')
expect(css).toContain('.today-environment{grid-template-columns:repeat(3,minmax(0,1fr));') expect(css).toContain('.today-environment{grid-template-columns:repeat(3,minmax(0,1fr));')
expect(css).toContain('@media(max-width:930px){main.today-main{padding-left:max(44px,calc((100% - 630px)/2));padding-right:max(44px,calc((100% - 630px)/2))}}') expect(css).toContain('@media(max-width:930px){main.today-main{padding-left:max(44px,calc((100% - 630px)/2));padding-right:max(44px,calc((100% - 630px)/2))}}')
expect(css).toContain('@media(max-width:720.98px){main.today-main{padding-left:29px!important;padding-right:29px!important;padding-bottom:calc(78px + var(--safe-area-bottom))}') expect(css).toContain('@media(max-width:720.98px){main.today-main{padding-left:var(--space-28)!important;padding-right:var(--space-28)!important;padding-bottom:calc(78px + var(--safe-area-bottom))}')
expect(css).toContain('.today-context{margin:0 0 14px;border-bottom:0}') expect(css).toContain('.today-context{margin:0 0 var(--space-14);border-bottom:0}')
expect(css).toContain('.today-heading{display:grid;') expect(css).toContain('.today-heading{display:grid;')
expect(css).toContain('.today-heading .today-remaining{margin:6px 0 0}') expect(css).toContain('.today-heading .today-remaining{margin:var(--space-6) 0 0}')
expect(css).not.toContain('.today-board{') expect(css).not.toContain('.today-board{')
expect(css).not.toContain('.today-board__progress{') expect(css).not.toContain('.today-board__progress{')
expect(css).not.toContain('.today-track{') expect(css).not.toContain('.today-track{')
+1 -1
View File
@@ -13,7 +13,7 @@ export function formatApiErrorDetail(detail: unknown): string {
if (typeof value.message === 'string') return value.message if (typeof value.message === 'string') return value.message
if (typeof value.code === 'string') return value.code if (typeof value.code === 'string') return value.code
} }
return '请求失败' return '请求失败,请检查网络后重试'
} }
export class ApiError extends Error { export class ApiError extends Error {
File diff suppressed because one or more lines are too long
+6 -5
View File
@@ -2,6 +2,7 @@
import { computed, nextTick, onMounted, onUnmounted, ref, watch } from 'vue' import { computed, nextTick, onMounted, onUnmounted, ref, watch } from 'vue'
import { ArchiveRestore, Bold, Code, Heading2, Italic, Link, List, ListChecks, ListOrdered, Quote, Trash2, X } from 'lucide-vue-next' import { ArchiveRestore, Bold, Code, Heading2, Italic, Link, List, ListChecks, ListOrdered, Quote, Trash2, X } from 'lucide-vue-next'
import { applyMarkdownFormat, renderMarkdown, type MarkdownFormat } from '../lib/task-utils' import { applyMarkdownFormat, renderMarkdown, type MarkdownFormat } from '../lib/task-utils'
import { failureHint } from '../failureHint'
export type MemoRecord = { id: string; title: string; content: string; version: number; created_at: string; updated_at: string; deleted_at: string | null } export type MemoRecord = { id: string; title: string; content: string; version: number; created_at: string; updated_at: string; deleted_at: string | null }
export type MemoDraft = { id: null; title: string; content: string; version: null; created_at: null; updated_at: null; deleted_at: null } export type MemoDraft = { id: null; title: string; content: string; version: null; created_at: null; updated_at: null; deleted_at: null }
@@ -67,7 +68,7 @@ async function save() {
if (!isCurrentSelection()) return if (!isCurrentSelection()) return
const status = (reason as { status?: number }).status const status = (reason as { status?: number }).status
conflict.value = memoId !== null && status === 409 conflict.value = memoId !== null && status === 409
error.value = conflict.value ? '版本冲突:草稿已保留,请重新载入后再保存。' : reason instanceof Error ? reason.message : memoId === null ? '创建失败' : '保存失败' error.value = conflict.value ? '版本冲突:草稿已保留,请重新载入后再保存。' : reason instanceof Error ? reason.message : memoId === null ? failureHint('创建失败', reason) : failureHint('保存失败', reason)
} finally { } finally {
emit('saveFinished', selectionToken) emit('saveFinished', selectionToken)
if (isCurrentSelection()) saving.value = false if (isCurrentSelection()) saving.value = false
@@ -84,7 +85,7 @@ async function reload() {
loadDraft(fresh) loadDraft(fresh)
emit('saved', fresh, selectionToken) emit('saved', fresh, selectionToken)
} catch (reason) { } catch (reason) {
if (isCurrentSelection()) error.value = reason instanceof Error ? reason.message : '重新载入失败' if (isCurrentSelection()) error.value = failureHint('重新载入失败', reason)
} }
} }
async function remove() { async function remove() {
@@ -101,7 +102,7 @@ async function remove() {
await props.request(`/memos/${memoId}`, { method: 'DELETE' }) await props.request(`/memos/${memoId}`, { method: 'DELETE' })
emit('deleted', memoId, selectionToken) emit('deleted', memoId, selectionToken)
if (isCurrentOperation()) emit('notice', '备忘录已移到回收站') if (isCurrentOperation()) emit('notice', '备忘录已移到回收站')
} catch (reason) { if (isCurrentOperation()) error.value = reason instanceof Error ? reason.message : '删除失败' } } catch (reason) { if (isCurrentOperation()) error.value = failureHint('删除失败', reason) }
finally { finally {
emit('lifecycleFinished', selectionToken) emit('lifecycleFinished', selectionToken)
if (isCurrentOperation()) lifecycleBusy.value = false if (isCurrentOperation()) lifecycleBusy.value = false
@@ -120,7 +121,7 @@ async function restore() {
const restored = await props.request(`/memos/${memoId}/restore`, { method: 'POST' }) as Memo const restored = await props.request(`/memos/${memoId}/restore`, { method: 'POST' }) as Memo
emit('restored', restored, selectionToken) emit('restored', restored, selectionToken)
if (isCurrentOperation()) emit('notice', '备忘录已恢复') if (isCurrentOperation()) emit('notice', '备忘录已恢复')
} catch (reason) { if (isCurrentOperation()) error.value = reason instanceof Error ? reason.message : '恢复失败' } } catch (reason) { if (isCurrentOperation()) error.value = failureHint('恢复失败', reason) }
finally { finally {
emit('lifecycleFinished', selectionToken) emit('lifecycleFinished', selectionToken)
if (isCurrentOperation()) lifecycleBusy.value = false if (isCurrentOperation()) lifecycleBusy.value = false
@@ -140,7 +141,7 @@ async function purge() {
await props.request(`/memos/${memoId}/purge`, { method: 'DELETE' }) await props.request(`/memos/${memoId}/purge`, { method: 'DELETE' })
emit('purged', memoId, selectionToken) emit('purged', memoId, selectionToken)
if (isCurrentOperation()) emit('notice', '备忘录已永久删除') if (isCurrentOperation()) emit('notice', '备忘录已永久删除')
} catch (reason) { if (isCurrentOperation()) error.value = reason instanceof Error ? reason.message : '永久删除失败' } } catch (reason) { if (isCurrentOperation()) error.value = failureHint('永久删除失败', reason) }
finally { finally {
emit('lifecycleFinished', selectionToken) emit('lifecycleFinished', selectionToken)
if (isCurrentOperation()) lifecycleBusy.value = false if (isCurrentOperation()) lifecycleBusy.value = false
@@ -131,7 +131,7 @@ describe('TodayEnvironmentStrip', () => {
const css = readFileSync(resolve(process.cwd(), 'src/style.css'), 'utf8') const css = readFileSync(resolve(process.cwd(), 'src/style.css'), 'utf8')
expect(css).toContain('.today-environment{grid-column:1/-1;position:relative;min-width:0;display:grid;grid-template-columns:minmax(0,1.25fr) minmax(0,1fr) minmax(0,1fr);align-items:stretch') expect(css).toContain('.today-environment{grid-column:1/-1;position:relative;min-width:0;display:grid;grid-template-columns:minmax(0,1.25fr) minmax(0,1fr) minmax(0,1fr);align-items:stretch')
expect(css).toContain('color:var(--text-primary);font-size:23px;line-height:1.15;font-weight:800') expect(css).toContain('color:var(--text-primary);font-size:23px;line-height:1.15;font-weight:800')
expect(css).toContain('margin-top:6px;color:var(--text-secondary);font-size:12px;font-weight:400') expect(css).toContain('margin-top:var(--space-6);color:var(--text-secondary);font-size:12px;font-weight:400')
expect(css).toContain('.today-environment__eyeline{color:var(--text-secondary);font-size:11px') expect(css).toContain('.today-environment__eyeline{color:var(--text-secondary);font-size:11px')
expect(css).toContain('.today-environment__item+.today-environment__item{border-left:1px solid var(--border-cream)}') expect(css).toContain('.today-environment__item+.today-environment__item{border-left:1px solid var(--border-cream)}')
expect(css).not.toContain('.today-environment.has-attention:before') expect(css).not.toContain('.today-environment.has-attention:before')
@@ -145,7 +145,7 @@ describe('TodayEnvironmentStrip', () => {
expect(css).toContain('.today-environment__calendar{grid-column:1/-1;grid-row:1;display:flex;flex-direction:row;align-items:baseline;justify-content:space-between') expect(css).toContain('.today-environment__calendar{grid-column:1/-1;grid-row:1;display:flex;flex-direction:row;align-items:baseline;justify-content:space-between')
expect(css).toContain('.today-environment__weather{grid-column:1;grid-row:2;border-top:1px solid var(--border-cream);border-left:0!important}') expect(css).toContain('.today-environment__weather{grid-column:1;grid-row:2;border-top:1px solid var(--border-cream);border-left:0!important}')
expect(css).toContain('.today-environment__gold{grid-column:2;grid-row:2;border-top:1px solid var(--border-cream)}') expect(css).toContain('.today-environment__gold{grid-column:2;grid-row:2;border-top:1px solid var(--border-cream)}')
expect(css).toContain('.today-environment__weather,.today-environment__gold{padding:13px 10px;display:flex;flex-direction:column;') expect(css).toContain('.today-environment__weather,.today-environment__gold{padding:var(--space-12) var(--space-10);display:flex;flex-direction:column;')
expect(css).toContain('.today-environment__calendar-main--desktop,.today-environment__gold-date--desktop{display:none}') expect(css).toContain('.today-environment__calendar-main--desktop,.today-environment__gold-date--desktop{display:none}')
expect(css).toContain('.today-environment__calendar-main--mobile,.today-environment__gold-date--mobile{display:inline}') expect(css).toContain('.today-environment__calendar-main--mobile,.today-environment__gold-date--mobile{display:inline}')
const mobileBlock = css.slice(css.indexOf('@media(max-width:720px){.today-environment'), css.indexOf('/* Solid cream material system')) const mobileBlock = css.slice(css.indexOf('@media(max-width:720px){.today-environment'), css.indexOf('/* Solid cream material system'))
+21
View File
@@ -0,0 +1,21 @@
import { describe, it, expect } from 'vitest'
import { failureHint } from './failureHint'
describe('failureHint', () => {
it('uses network hint without error', () => {
expect(failureHint('保存失败')).toBe('保存失败,请检查网络后重试')
})
it('classifies TypeError as network', () => {
const e = new TypeError('Failed to fetch')
expect(failureHint('请求失败', e)).toBe('请求失败,请检查网络后重试')
})
it('classifies 401 as auth', () => {
expect(failureHint('加载失败', { status: 401 })).toBe('加载失败,请重新登录后再试')
})
it('classifies 5xx as server', () => {
expect(failureHint('保存失败', { status: 502 })).toBe('保存失败,服务器出错,请稍后重试')
})
it('keeps Chinese server detail', () => {
expect(failureHint('保存失败', new Error('重复规则冲突'))).toBe('保存失败:重复规则冲突')
})
})
+17
View File
@@ -0,0 +1,17 @@
type FailErr = unknown
/** 错误提示统一出口:动作短语 + 可操作后缀(网络/鉴权/校验/服务端四类) */
export function failureHint(phrase: string, err?: FailErr): string {
if (!err) return `${phrase},请检查网络后重试`
const e = err as { name?: string; message?: string; status?: number; response?: { status?: number } }
const name = String(e.name ?? "")
const msg = String(e.message ?? "")
const status = Number(e.status ?? e.response?.status ?? 0)
const blob = `${name} ${msg}`
if (status === 401 || status === 403 || /unauthorized|\b401\b|登录|token/i.test(blob)) return `${phrase},请重新登录后再试`
if (name === 'TypeError' || /failed to fetch|networkerror|network request failed|net::|fetch/i.test(blob)) return `${phrase},请检查网络后重试`
if (status >= 500) return `${phrase},服务器出错,请稍后重试`
if (status >= 400) return `${phrase},请检查填写内容后重试`
if (/\p{Script=Han}/u.test(msg)) return `${phrase}${msg}`
return `${phrase},请重试`
}
+1 -1
View File
@@ -894,5 +894,5 @@ export function formatApiErrorDetail(detail: unknown): string {
if ('detail' in record) return formatApiErrorDetail(record.detail) if ('detail' in record) return formatApiErrorDetail(record.detail)
return JSON.stringify(record) return JSON.stringify(record)
} }
return '请求失败' return '请求失败,请检查网络后重试'
} }
+2 -2
View File
@@ -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;gap:12px}.memo-scope{display:flex;gap:4px;padding:3px;border:1px solid var(--border-cream);border-radius:var(--radius-control);background:var(--surface-raised)}.memo-scope button{min-height:44px;display:inline-flex;align-items:center;gap:6px;border:0;border-radius:var(--radius-control);background:transparent;padding:0 13px}.memo-scope button[aria-selected="true"]{background:var(--accent-soft);color:var(--accent-ink);font-weight:700}.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:var(--surface-hover)}.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(--text-secondary);font-size:11px}.memo-state{min-height:240px;display:grid;place-items:center;align-content:center;gap:9px;color:var(--text-secondary);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:var(--error-wash);border-radius:var(--radius-control);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:700;letter-spacing:.06em;color:var(--text-secondary)}.memo-editor header button{width:44px;height:44px;display:grid;place-items:center;border:0;border-radius:var(--radius-control);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:var(--radius-control);background:var(--white);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;gap:12px}.memo-scope{display:flex;gap:4px;padding:var(--space-2);border:1px solid var(--border-cream);border-radius:var(--radius-control);background:var(--surface-raised)}.memo-scope button{min-height:44px;display:inline-flex;align-items:center;gap:6px;border:0;border-radius:var(--radius-control);background:transparent;padding:0 var(--space-12)}.memo-scope button[aria-selected="true"]{background:var(--accent-soft);color:var(--accent-ink);font-weight:700}.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:var(--space-8) var(--space-14);text-align:left}.memo-row+.memo-row{border-top:1px solid var(--border-cream)}.memo-row:hover,.memo-row.active{background:var(--surface-hover)}.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(--text-secondary);font-size:11px}.memo-state{min-height:240px;display:grid;place-items:center;align-content:center;gap:9px;color:var(--text-secondary);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:var(--error-wash);border-radius:var(--radius-control);padding:var(--space-10) var(--space-12)}.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:var(--space-10) var(--space-16);border-bottom:1px solid var(--border-cream)}.memo-editor header span{font-size:12px;font-weight:700;letter-spacing:.06em;color:var(--text-secondary)}.memo-editor header button{width:44px;height:44px;display:grid;place-items:center;border:0;border-radius:var(--radius-control);background:transparent}.memo-editor__fields{flex:1;min-height:0;overflow-y:auto;overflow-x:hidden;display:grid;align-content:start;gap:14px;padding:var(--space-18)}.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:var(--radius-control);background:var(--white);padding:var(--space-12);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:block}.memo-scope{min-width:0}.memo-scope button{flex:1;justify-content:center;padding-inline:9px}.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(max-width:930px){.shell.memo-detail-open main{padding:var(--space-20) var(--space-16) 112px}.memo-panel{min-height:calc(100dvh - 150px)}.memo-toolbar{display:block}.memo-scope{min-width:0}.memo-scope button{flex:1;justify-content:center;padding-inline:var(--space-8)}.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:var(--space-16)}.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}}
+110 -84
View File
File diff suppressed because one or more lines are too long
+29 -29
View File
@@ -79,10 +79,10 @@ describe('unified task due display', () => {
describe('approved cream solid button system', () => { describe('approved cream solid button system', () => {
it('gives semantic action buttons a compact desktop size and a touch-safe mobile size', () => { it('gives semantic action buttons a compact desktop size and a touch-safe mobile size', () => {
expect(css).toContain(':is(.primary,.primary-small,.soft-button,.secondary,.restore,.file-button,.danger-button){min-height:40px;display:inline-flex;align-items:center;justify-content:center;gap:6px;padding:0 15px;border-radius:var(--radius-control);font-size:13px;font-weight:700;line-height:1;white-space:nowrap;transition:transform .14s ease,background-color .14s ease,border-color .14s ease,box-shadow .14s ease,color .14s ease}') expect(css).toContain(':is(.primary,.primary-small,.soft-button,.secondary,.restore,.file-button,.danger-button){min-height:40px;display:inline-flex;align-items:center;justify-content:center;gap:6px;padding:0 var(--space-14);border-radius:var(--radius-control);font-size:13px;font-weight:700;line-height:1;white-space:nowrap;transition:transform .14s ease,background-color .14s ease,border-color .14s ease,box-shadow .14s ease,color .14s ease}')
expect(css).toContain(':is(.primary,.primary-small,.soft-button,.secondary,.restore,.file-button,.danger-button)>svg{width:16px;height:16px;flex:0 0 auto}') expect(css).toContain(':is(.primary,.primary-small,.soft-button,.secondary,.restore,.file-button,.danger-button)>svg{width:16px;height:16px;flex:0 0 auto}')
expect(css).toContain('@media(min-width:931px){main:has(>.mvp-view .settings-sections) :is(.soft-button,.primary-small,.danger-button,.file-button),.numeric-action .soft-button,.habit-history__message .soft-button,.backup-preflight .danger-button,.app-sheet__danger :is(.soft-button,.primary-small,.danger-button,.file-button,.secondary,.restore),.purge-list-dialog :is(.secondary,.danger-button){min-height:40px}}') expect(css).toContain('@media(min-width:931px){main:has(>.mvp-view .settings-sections) :is(.soft-button,.primary-small,.danger-button,.file-button),.numeric-action .soft-button,.habit-history__message .soft-button,.backup-preflight .danger-button,.app-sheet__danger :is(.soft-button,.primary-small,.danger-button,.file-button,.secondary,.restore),.purge-list-dialog :is(.secondary,.danger-button){min-height:40px}}')
expect(css).toContain('@media(max-width:930px){:is(.primary,.primary-small,.soft-button,.secondary,.restore,.file-button,.danger-button){min-height:44px;padding-inline:16px;gap:7px}:is(.primary,.primary-small,.soft-button,.secondary,.restore,.file-button,.danger-button)>svg{width:17px;height:17px}}') expect(css).toContain('@media(max-width:930px){:is(.primary,.primary-small,.soft-button,.secondary,.restore,.file-button,.danger-button){min-height:44px;padding-inline:var(--space-16);gap:7px}:is(.primary,.primary-small,.soft-button,.secondary,.restore,.file-button,.danger-button)>svg{width:17px;height:17px}}')
expect(css).toContain(':is(.primary,.primary-small){border:1px solid var(--accent);background:var(--accent);color:var(--white);box-shadow:inset 0 1px 0 rgba(255,255,255,.36),0 4px 10px rgba(241,90,41,.20)}') expect(css).toContain(':is(.primary,.primary-small){border:1px solid var(--accent);background:var(--accent);color:var(--white);box-shadow:inset 0 1px 0 rgba(255,255,255,.36),0 4px 10px rgba(241,90,41,.20)}')
expect(css).toContain(':is(.soft-button,.secondary,.restore,.file-button){border:1px solid var(--soft-button-border-2);background:var(--row-hover);color:var(--soft-button-ink);box-shadow:inset 0 1px 0 var(--white),0 2px 5px rgba(83,62,42,.06)}') expect(css).toContain(':is(.soft-button,.secondary,.restore,.file-button){border:1px solid var(--soft-button-border-2);background:var(--row-hover);color:var(--soft-button-ink);box-shadow:inset 0 1px 0 var(--white),0 2px 5px rgba(83,62,42,.06)}')
expect(css).toContain('.danger-button{border:1px solid var(--danger-button-border-3);background:var(--danger-btn-bg);color:var(--danger-button-ink);box-shadow:inset 0 1px 0 var(--white),0 2px 5px rgba(105,48,34,.05)}') expect(css).toContain('.danger-button{border:1px solid var(--danger-button-border-3);background:var(--danger-btn-bg);color:var(--danger-button-ink);box-shadow:inset 0 1px 0 var(--white),0 2px 5px rgba(105,48,34,.05)}')
@@ -171,15 +171,15 @@ describe('approved Settings 01 paper ledger', () => {
it('uses the selected continuous-paper geometry on desktop and mobile', () => { it('uses the selected continuous-paper geometry on desktop and mobile', () => {
expect(css).toContain('main:has(>.mvp-view .settings-sections){background:var(--surface-raised)}') expect(css).toContain('main:has(>.mvp-view .settings-sections){background:var(--surface-raised)}')
expect(css).toContain('.settings-sections{width:min(100%,900px);margin:0 auto;display:grid;gap:0;padding:34px 0 64px}') expect(css).toContain('.settings-sections{width:min(100%,900px);margin:0 auto;display:grid;gap:0;padding:var(--space-32) 0 64px}')
expect(css).toContain('.settings-heading h1{font-size:34px;line-height:1.1;font-weight:700;') expect(css).toContain('.settings-heading h1{font-size:34px;line-height:1.1;font-weight:700;')
expect(css).toContain('.settings-group{min-width:0;background:transparent;border:0;border-radius:0;box-shadow:none;overflow:visible;padding-top:25px}') expect(css).toContain('.settings-group{min-width:0;background:transparent;border:0;border-radius:0;box-shadow:none;overflow:visible;padding-top:var(--space-24)}')
expect(css).toContain('.settings-group>header{height:44px;padding:0;display:flex;align-items:center;border-bottom:1px solid var(--border-hairline)}') expect(css).toContain('.settings-group>header{height:44px;padding:0;display:flex;align-items:center;border-bottom:1px solid var(--border-hairline)}')
expect(css).toContain('.settings-row{min-height:62px;padding:6px 0;border-top:0;border-bottom:1px solid var(--border-hairline);') expect(css).toContain('.settings-row{min-height:62px;padding:var(--space-6) 0;border-top:0;border-bottom:1px solid var(--border-hairline);')
expect(css).toContain('.settings-topbar{margin-bottom:0}') expect(css).toContain('.settings-topbar{margin-bottom:0}')
expect(css).not.toContain('.topbar>.settings-refresh{grid-area:filter}') expect(css).not.toContain('.topbar>.settings-refresh{grid-area:filter}')
expect(css).toContain('@media(max-width:720px){main:has(>.mvp-view .settings-sections){padding-left:29px;padding-right:29px;padding-bottom:calc(102px + env(safe-area-inset-bottom))}') expect(css).toContain('@media(max-width:720px){main:has(>.mvp-view .settings-sections){padding-left:var(--space-28);padding-right:var(--space-28);padding-bottom:calc(102px + env(safe-area-inset-bottom))}')
expect(css).toContain('.settings-sections{width:100%;padding-top:23px;padding-bottom:0}') expect(css).toContain('.settings-sections{width:100%;padding-top:var(--space-24);padding-bottom:0}')
expect(css).toContain('.settings-heading h1{font-size:24px;line-height:1.1;font-weight:700}') expect(css).toContain('.settings-heading h1{font-size:24px;line-height:1.1;font-weight:700}')
expect(css).toContain('.settings-row{min-height:64px}') expect(css).toContain('.settings-row{min-height:64px}')
}) })
@@ -391,12 +391,12 @@ describe('approved UI detail direction', () => {
it('matches the approved paper-flow geometry and material', () => { it('matches the approved paper-flow geometry and material', () => {
expect(css).toContain('.shell.detail-open{grid-template-columns:var(--sidebar-width,236px) minmax(330px,1fr) var(--detail-width,350px)}') expect(css).toContain('.shell.detail-open{grid-template-columns:var(--sidebar-width,236px) minmax(330px,1fr) var(--detail-width,350px)}')
expect(css).toContain('@media(max-width:1050px) and (min-width:931px){main{padding-inline:24px}') expect(css).toContain('@media(max-width:1050px) and (min-width:931px){main{padding-inline:var(--space-24)}')
expect(css).toContain('.detail{min-width:0;border-left:1px solid var(--border-cream);background:var(--surface-raised);overflow:hidden;display:flex;flex-direction:column}') expect(css).toContain('.detail{min-width:0;border-left:1px solid var(--border-cream);background:var(--surface-raised);overflow:hidden;display:flex;flex-direction:column}')
expect(css).toContain('.detail-head{height:58px;flex:0 0 58px;display:flex;align-items:center;justify-content:space-between;padding:0 18px;') expect(css).toContain('.detail-head{height:58px;flex:0 0 58px;display:flex;align-items:center;justify-content:space-between;padding:0 var(--space-18);')
expect(css).toContain('.detail-form{min-width:0;border:0;margin:0;grid-template-columns:minmax(0,1fr);padding:18px;display:grid;gap:18px;overflow-y:auto;') expect(css).toContain('.detail-form{min-width:0;border:0;margin:0;grid-template-columns:minmax(0,1fr);padding:var(--space-18);display:grid;gap:18px;overflow-y:auto;')
expect(css).toContain('.detail-title textarea{min-width:0;min-height:62px;padding:0;border:0;background:transparent;box-shadow:none;resize:none;outline:none;font-size:20px;line-height:27px;') expect(css).toContain('.detail-title textarea{min-width:0;min-height:62px;padding:0;border:0;background:transparent;box-shadow:none;resize:none;outline:none;font-size:20px;line-height:27px;')
expect(css).toContain('.task-detail-arrangement{display:grid;gap:13px;padding-bottom:18px;border-bottom:1px solid var(--border-section)}') expect(css).toContain('.task-detail-arrangement{display:grid;gap:13px;padding-bottom:var(--space-18);border-bottom:1px solid var(--border-section)}')
expect(css).toContain('.task-detail-date-time{display:grid;grid-template-columns:minmax(0,1fr) minmax(0,1fr);gap:10px}') expect(css).toContain('.task-detail-date-time{display:grid;grid-template-columns:minmax(0,1fr) minmax(0,1fr);gap:10px}')
expect(css).toContain('.task-detail-field-input{width:100%!important;min-height:44px;border-radius:var(--radius-control)}') expect(css).toContain('.task-detail-field-input{width:100%!important;min-height:44px;border-radius:var(--radius-control)}')
expect(css).toContain('.detail-actions{position:sticky;bottom:0;z-index:3;min-height:69px;') expect(css).toContain('.detail-actions{position:sticky;bottom:0;z-index:3;min-height:69px;')
@@ -420,7 +420,7 @@ describe('approved UI detail direction', () => {
it('keeps custom recurrence controls at 44px without overflowing narrow screens', () => { it('keeps custom recurrence controls at 44px without overflowing narrow screens', () => {
expect(css).toContain('.repeat-custom-fields input,.repeat-custom-fields select{min-height:44px;') expect(css).toContain('.repeat-custom-fields input,.repeat-custom-fields select{min-height:44px;')
expect(css).toContain('.weekday-picker label{width:44px;height:44px;') expect(css).toContain('.weekday-picker label{width:44px;height:44px;')
expect(css).toContain('@media(max-width:930px){.repeat-custom-fields{padding:10px;gap:8px}') expect(css).toContain('@media(max-width:930px){.repeat-custom-fields{padding:var(--space-10);gap:8px}')
expect(css).toContain('.repeat-custom-fields>div,.repeat-custom-fields>label{min-width:0;flex-wrap:wrap;gap:6px}') expect(css).toContain('.repeat-custom-fields>div,.repeat-custom-fields>label{min-width:0;flex-wrap:wrap;gap:6px}')
}) })
@@ -484,7 +484,7 @@ describe('completion feedback motion', () => {
}) })
it('stacks the desktop calendar copy to align with weather and gold', () => { it('stacks the desktop calendar copy to align with weather and gold', () => {
expect(css).toContain('@media(min-width:721px){.today-environment__calendar{flex-direction:column;align-items:flex-start;gap:0}.today-environment__calendar small{margin-top:2px;font-size:11px}}') expect(css).toContain('@media(min-width:721px){.today-environment__calendar{flex-direction:column;align-items:flex-start;gap:0}.today-environment__calendar small{margin-top:var(--space-2);font-size:11px}}')
}) })
it('uses the approved internal-standard login composition and material tokens', () => { it('uses the approved internal-standard login composition and material tokens', () => {
@@ -495,12 +495,12 @@ describe('completion feedback motion', () => {
expect(app).toContain('<p v-if="initialized" class="auth-footnote">登录后继续你的清单</p>') expect(app).toContain('<p v-if="initialized" class="auth-footnote">登录后继续你的清单</p>')
expect(app).toContain('<form class="auth-card" aria-labelledby="auth-title" @submit.prevent="submitAuth">') expect(app).toContain('<form class="auth-card" aria-labelledby="auth-title" @submit.prevent="submitAuth">')
expect(app).toContain('<button type="submit" class="primary">') expect(app).toContain('<button type="submit" class="primary">')
expect(css).toMatch(/\.auth-card\{[^}]*width:min\(400px,100%\)[^}]*padding:36px[^}]*border:1px solid var\(--border-cream\)[^}]*border-radius:var\(--radius-card\)/) expect(css).toMatch(/\.auth-card\{[^}]*width:min\(400px,100%\)[^}]*padding:var\(--space-36\)[^}]*border:1px solid var\(--border-cream\)[^}]*border-radius:var\(--radius-card\)/)
expect(css).toContain('.auth-brand .brand{gap:9px;font-size:28px;letter-spacing:-2px}') expect(css).toContain('.auth-brand .brand{gap:9px;font-size:28px;letter-spacing:-2px}')
expect(css).toContain('.auth-brand .brand-logo{width:34px;height:34px}') expect(css).toContain('.auth-brand .brand-logo{width:34px;height:34px}')
expect(css).toMatch(/\.auth-card input\{[^}]*min-height:46px[^}]*background:var\(--surface-raised\)/) expect(css).toMatch(/\.auth-card input\{[^}]*min-height:46px[^}]*background:var\(--surface-raised\)/)
expect(css).toContain('.auth-card>.primary{min-height:46px}') expect(css).toContain('.auth-card>.primary{min-height:46px}')
expect(css).toMatch(/@media\(max-width:930px\)\{\.auth-shell\{padding:18px\}\.auth-card\{padding:30px 24px\}/) expect(css).toMatch(/@media\(max-width:930px\)\{\.auth-shell\{padding:var\(--space-18\)\}\.auth-card\{padding:var\(--space-28\) var\(--space-24\)\}/)
}) })
it('syncs browser IANA timezone before loading user task data', () => { it('syncs browser IANA timezone before loading user task data', () => {
@@ -526,8 +526,8 @@ describe('completion feedback motion', () => {
expect(css).toContain('.markdown-toolbar{display:flex;') expect(css).toContain('.markdown-toolbar{display:flex;')
expect(css).toContain('overflow-x:auto') expect(css).toContain('overflow-x:auto')
expect(css).toContain('.markdown-toolbar button{min-width:44px;min-height:44px;') expect(css).toContain('.markdown-toolbar button{min-width:44px;min-height:44px;')
expect(css).toContain('.markdown-preview>.contains-task-list{list-style:none;padding-left:3px}') expect(css).toContain('.markdown-preview>.contains-task-list{list-style:none;padding-left:var(--space-2)}')
expect(css).toContain('.markdown-preview .contains-task-list .contains-task-list{padding-left:24px}') expect(css).toContain('.markdown-preview .contains-task-list .contains-task-list{padding-left:var(--space-24)}')
}) })
it('keeps all task detail changes on the unified save button', () => { it('keeps all task detail changes on the unified save button', () => {
@@ -545,7 +545,7 @@ describe('completion feedback motion', () => {
it('keeps the desktop task composer compact and centered', () => { it('keeps the desktop task composer compact and centered', () => {
expect(css).toContain('@media(min-width:931px){.task-compose-sheet:not(.habit-compose-sheet){width:min(560px,calc(100vw - 48px));height:min(520px,82dvh);max-height:none;padding:0;display:flex;flex-direction:column;gap:0;overflow:hidden}') expect(css).toContain('@media(min-width:931px){.task-compose-sheet:not(.habit-compose-sheet){width:min(560px,calc(100vw - 48px));height:min(520px,82dvh);max-height:none;padding:0;display:flex;flex-direction:column;gap:0;overflow:hidden}')
expect(css).toContain('.app-sheet-mask:has(>.task-compose-sheet:not(.habit-compose-sheet)){place-items:center;padding:24px}') expect(css).toContain('.app-sheet-mask:has(>.task-compose-sheet:not(.habit-compose-sheet)){place-items:center;padding:var(--space-24)}')
}) })
it('keeps all task composer touch controls at least 44px on mobile without horizontal overflow', () => { it('keeps all task composer touch controls at least 44px on mobile without horizontal overflow', () => {
@@ -1072,7 +1072,7 @@ describe('task and habit row decoration', () => {
const mobileLayout = css.slice(css.indexOf('@media(max-width:930px){.shell')) const mobileLayout = css.slice(css.indexOf('@media(max-width:930px){.shell'))
expect(mobileLayout).toContain('.completed-filter-pill{min-width:138px;height:44px') expect(mobileLayout).toContain('.completed-filter-pill{min-width:138px;height:44px')
expect(css).not.toContain('width:min(260px,100%)') expect(css).not.toContain('width:min(260px,100%)')
expect(css).not.toContain('.today-completed-toolbar{margin-top:8px}') expect(css).not.toContain('.today-completed-toolbar{margin-top:var(--space-8)}')
expect(css).not.toContain('.habit-toolbar{') expect(css).not.toContain('.habit-toolbar{')
}) })
@@ -1368,7 +1368,7 @@ describe('task detail layout', () => {
expect(app).toContain('v-model="selectedDueDate" class="task-detail-due-input task-detail-field-input"') expect(app).toContain('v-model="selectedDueDate" class="task-detail-due-input task-detail-field-input"')
expect(app.match(/class="task-detail-field-input"/g)).toHaveLength(2) expect(app.match(/class="task-detail-field-input"/g)).toHaveLength(2)
expect(css).toContain('.task-detail-field-input{width:100%!important;min-height:44px;border-radius:var(--radius-control)}') expect(css).toContain('.task-detail-field-input{width:100%!important;min-height:44px;border-radius:var(--radius-control)}')
expect(css).toContain('.task-detail-due-input{padding-inline:9px!important}') expect(css).toContain('.task-detail-due-input{padding-inline:var(--space-8)!important}')
}) })
}) })
@@ -1432,7 +1432,7 @@ describe('unified floating add interaction', () => {
const dueRemovalBlock = saveBlock.slice(saveBlock.indexOf('if (!taskSaved.due_at) {'), saveBlock.indexOf('} else {')) const dueRemovalBlock = saveBlock.slice(saveBlock.indexOf('if (!taskSaved.due_at) {'), saveBlock.indexOf('} else {'))
expect(dueRemovalBlock).not.toContain('saveRepeat(') expect(dueRemovalBlock).not.toContain('saveRepeat(')
expect(saveBlock).toContain("selectedRepeatError.value = ''") expect(saveBlock).toContain("selectedRepeatError.value = ''")
expect(saveBlock).toContain("selectedRepeatError.value = reason instanceof Error ? reason.message : '保存失败'") expect(saveBlock).toContain("selectedRepeatError.value = failureHint('保存失败', reason)")
expect(saveBlock.indexOf("toast('已保存')")).toBeGreaterThan(saveBlock.indexOf('await saveRepeat(taskSaved, repeatValue, repeatConfig, afterCompletionDays, recurrence)')) expect(saveBlock.indexOf("toast('已保存')")).toBeGreaterThan(saveBlock.indexOf('await saveRepeat(taskSaved, repeatValue, repeatConfig, afterCompletionDays, recurrence)'))
expect(saveBlock).toContain('if (savingSelectedTask.value || recurrenceLoading.value) return') expect(saveBlock).toContain('if (savingSelectedTask.value || recurrenceLoading.value) return')
expect(saveBlock).toContain('savingSelectedTask.value = true') expect(saveBlock).toContain('savingSelectedTask.value = true')
@@ -1614,8 +1614,8 @@ describe('sidebar information hierarchy', () => {
expect(archiveListBlock).toContain("confirmAction(`归档清单「${item.name}」?`, '任务会保留,可从“已归档”恢复')") expect(archiveListBlock).toContain("confirmAction(`归档清单「${item.name}」?`, '任务会保留,可从“已归档”恢复')")
expect(archiveListBlock).not.toContain("askText(`归档清单") expect(archiveListBlock).not.toContain("askText(`归档清单")
expect(css).toContain('.list-editor-sheet{width:min(460px,calc(100vw - 24px));') expect(css).toContain('.list-editor-sheet{width:min(460px,calc(100vw - 24px));')
expect(css).toContain('@media(min-width:931px){.app-sheet-mask:has(.list-editor-sheet){place-items:center;padding:24px}.app-sheet-mask:has(.list-editor-sheet) .list-editor-sheet{margin:0}}') expect(css).toContain('@media(min-width:931px){.app-sheet-mask:has(.list-editor-sheet){place-items:center;padding:var(--space-24)}.app-sheet-mask:has(.list-editor-sheet) .list-editor-sheet{margin:0}}')
expect(css).toContain('.list-editor-form{display:grid;gap:17px;padding:18px 20px}') expect(css).toContain('.list-editor-form{display:grid;gap:17px;padding:var(--space-18) var(--space-20)}')
expect(css).toContain('.list-editor-danger{border-top:1px solid var(--border-cream);') expect(css).toContain('.list-editor-danger{border-top:1px solid var(--border-cream);')
expect(app).toContain('@keydown.esc="sidebarCreateOpen=false;closeArchivedListAction();closeSidebarAction()"') expect(app).toContain('@keydown.esc="sidebarCreateOpen=false;closeArchivedListAction();closeSidebarAction()"')
expect(app).toContain('sidebarCreateOpen.value = false; sidebarAction.value = null') expect(app).toContain('sidebarCreateOpen.value = false; sidebarAction.value = null')
@@ -1631,8 +1631,8 @@ describe('quiet index sidebar parity', () => {
expect(css).toContain('.sidebar{border-right:1px solid var(--border-cream);background:var(--surface-canvas);') expect(css).toContain('.sidebar{border-right:1px solid var(--border-cream);background:var(--surface-canvas);')
expect(css).toContain('.brand-row{height:68px;') expect(css).toContain('.brand-row{height:68px;')
expect(css).toContain('border-bottom:1px solid rgba(222,205,185,.75)') expect(css).toContain('border-bottom:1px solid rgba(222,205,185,.75)')
expect(css).toContain('.primary-nav{display:grid;padding:10px 11px 8px;gap:2px}') expect(css).toContain('.primary-nav{display:grid;padding:var(--space-10) var(--space-10) var(--space-8);gap:2px}')
expect(css).toContain('@media(max-width:1050px) and (min-width:931px){main{padding-inline:24px}') expect(css).toContain('@media(max-width:1050px) and (min-width:931px){main{padding-inline:var(--space-24)}')
expect(css).not.toContain('grid-template-columns:220px') expect(css).not.toContain('grid-template-columns:220px')
expect(css).toContain('@media(max-width:930px){.shell') expect(css).toContain('@media(max-width:930px){.shell')
expect(css).toContain('left:0;width:236px;max-width:86vw;') expect(css).toContain('left:0;width:236px;max-width:86vw;')
@@ -1642,9 +1642,9 @@ describe('quiet index sidebar parity', () => {
expect(css).toContain('.primary-nav button.active{background:var(--nav-active-bg);color:var(--accent-ink);') expect(css).toContain('.primary-nav button.active{background:var(--nav-active-bg);color:var(--accent-ink);')
expect(css).toContain('.primary-nav button.active::before,.list-row.active::before,.sidebar-management button.active::before{') expect(css).toContain('.primary-nav button.active::before,.list-row.active::before,.sidebar-management button.active::before{')
expect(css).toContain('width:3px;height:18px;') expect(css).toContain('width:3px;height:18px;')
expect(css).toContain('.section-title{padding:14px 15px 5px 20px;') expect(css).toContain('.section-title{padding:var(--space-14) var(--space-14) var(--space-4) var(--space-20);')
expect(css).toContain('.folders{flex:1;min-height:0;padding:0 10px;') expect(css).toContain('.folders{flex:1;min-height:0;padding:0 var(--space-10);')
expect(css).toContain('.folder-row>button,.list-row-main{flex:1;min-width:0;min-height:44px;border:0;background:transparent;padding:0 8px;') expect(css).toContain('.folder-row>button,.list-row-main{flex:1;min-width:0;min-height:44px;border:0;background:transparent;padding:0 var(--space-8);')
expect(css).toContain('.list-row.active{background:var(--list-row-bg);color:var(--accent-ink);box-shadow:inset 0 0 0 1px var(--list-row-shadow)}') expect(css).toContain('.list-row.active{background:var(--list-row-bg);color:var(--accent-ink);box-shadow:inset 0 0 0 1px var(--list-row-shadow)}')
}) })
@@ -1790,7 +1790,7 @@ describe('sidebar layout', () => {
expect(app).toContain('@close="closePurgeList"') expect(app).toContain('@close="closePurgeList"')
expect(app).not.toContain('handlePurgeDialogKeydown') expect(app).not.toContain('handlePurgeDialogKeydown')
expect(app).toContain('if (purgeListSubmitting.value) return') expect(app).toContain('if (purgeListSubmitting.value) return')
expect(app).toContain('purgeListError.value = reason instanceof Error ? reason.message : \'永久删除失败\'') expect(app).toContain("purgeListError.value = failureHint('永久删除失败', reason)")
expect(app).toContain(':disabled="purgeListSubmitting"') expect(app).toContain(':disabled="purgeListSubmitting"')
expect(app).toContain('role="alert" class="purge-list-error"') expect(app).toContain('role="alert" class="purge-list-error"')
expect(css).toContain('.purge-list-dialog button{min-height:44px;') expect(css).toContain('.purge-list-dialog button{min-height:44px;')
+1 -1
View File
@@ -1,4 +1,4 @@
.today-section-toggle{width:100%;min-height:44px;display:flex;align-items:center;padding:0;border:0;border-bottom:1px solid var(--border-hairline);background:transparent;color:var(--text-primary);text-align:left;border-radius:0}.today-section-toggle:hover{background:transparent}.today-section-toggle:focus-visible{outline:2px solid var(--accent);outline-offset:2px}.today-section-title{display:flex;align-items:center;font-weight:700;font-size:13px;letter-spacing:.02em}.today-section-summary{margin-left:auto;color:var(--text-secondary);font-size:12px;font-weight:400}.today-section-chevron{width:14px;margin-left:3px;color:var(--text-secondary);font-size:13px;line-height:1;text-align:right}.today-collapsible-section,.today-habits-section{min-width:0}.today-habits-section.today-section-anchor{scroll-margin-top:18px} .today-section-toggle{width:100%;min-height:44px;display:flex;align-items:center;padding:0;border:0;border-bottom:1px solid var(--border-hairline);background:transparent;color:var(--text-primary);text-align:left;border-radius:0}.today-section-toggle:hover{background:transparent}.today-section-toggle:focus-visible{outline:3px solid var(--focus-ring);outline-offset:2px}.today-section-title{display:flex;align-items:center;font-weight:700;font-size:13px;letter-spacing:.02em}.today-section-summary{margin-left:auto;color:var(--text-secondary);font-size:12px;font-weight:400}.today-section-chevron{width:14px;margin-left:var(--space-2);color:var(--text-secondary);font-size:13px;line-height:1;text-align:right}.today-collapsible-section,.today-habits-section{min-width:0}.today-habits-section.today-section-anchor{scroll-margin-top:18px}
/* Restrained motion: paper unfold/put-away for the three Today sections (grid-rows 0fr<->1fr 180ms). */ /* Restrained motion: paper unfold/put-away for the three Today sections (grid-rows 0fr<->1fr 180ms). */
+9 -9
View File
@@ -93,10 +93,10 @@ describe('approved five-detail polish', () => {
expect(app).toContain('<span class="pager-status" aria-live="polite"><strong>{{page}} / {{totalPages}}</strong><span>共 {{ totalTasks }} 项</span></span>') expect(app).toContain('<span class="pager-status" aria-live="polite"><strong>{{page}} / {{totalPages}}</strong><span>共 {{ totalTasks }} 项</span></span>')
expect(app).toContain('class="pager-button pager-button--next"') expect(app).toContain('class="pager-button pager-button--next"')
expect(app).toContain('<span>下一页</span><ChevronRight aria-hidden="true"/>') expect(app).toContain('<span>下一页</span><ChevronRight aria-hidden="true"/>')
expect(css).toContain('.pager{min-height:56px;display:grid;grid-template-columns:minmax(0,1fr) auto minmax(0,1fr);align-items:center;gap:8px;margin:8px 0 14px;border-top:1px solid var(--border-hairline);background:transparent}') expect(css).toContain('.pager{min-height:56px;display:grid;grid-template-columns:minmax(0,1fr) auto minmax(0,1fr);align-items:center;gap:8px;margin:var(--space-8) 0 var(--space-14);border-top:1px solid var(--border-hairline);background:transparent}')
expect(css).toContain('.pager-button{min-width:0;min-height:44px;padding:0 8px;display:flex;align-items:center;justify-content:center;gap:6px;border:0;border-radius:var(--radius-control);background:transparent;color:var(--accent-ink);font-size:13px;font-weight:600;box-shadow:none;transition:transform .14s ease,background-color .14s ease,color .14s ease}') expect(css).toContain('.pager-button{min-width:0;min-height:44px;padding:0 var(--space-8);display:flex;align-items:center;justify-content:center;gap:6px;border:0;border-radius:var(--radius-control);background:transparent;color:var(--accent-ink);font-size:13px;font-weight:600;box-shadow:none;transition:transform .14s ease,background-color .14s ease,color .14s ease}')
expect(css).toContain('.pager-status{min-width:72px;display:grid;justify-items:center;gap:1px;color:var(--text-secondary);font-size:11px;font-variant-numeric:tabular-nums}') expect(css).toContain('.pager-status{min-width:72px;display:grid;justify-items:center;gap:1px;color:var(--text-secondary);font-size:11px;font-variant-numeric:tabular-nums}')
expect(css).toContain('@media(max-width:390px){.pager-button{padding:0 4px}.pager{grid-template-columns:minmax(0,1fr) 72px minmax(0,1fr);gap:2px}}') expect(css).toContain('@media(max-width:390px){.pager-button{padding:0 var(--space-4)}.pager{grid-template-columns:minmax(0,1fr) 72px minmax(0,1fr);gap:2px}}')
expect(app).toContain("if (activeView.value === 'upcoming') { openParams.set('due_from', isoAtLocalDayOffset(0)); openParams.set('due_to', isoAtLocalDayOffset(8)) }") expect(app).toContain("if (activeView.value === 'upcoming') { openParams.set('due_from', isoAtLocalDayOffset(0)); openParams.set('due_to', isoAtLocalDayOffset(8)) }")
expect(app).toContain("new Date(task.due_at) >= startOfLocalDay(0) && new Date(task.due_at) < startOfLocalDay(8)") expect(app).toContain("new Date(task.due_at) >= startOfLocalDay(0) && new Date(task.due_at) < startOfLocalDay(8)")
expect(app).not.toContain('class="list-search-clear"') expect(app).not.toContain('class="list-search-clear"')
@@ -109,9 +109,9 @@ describe('approved five-detail polish', () => {
expect(app).toContain('class="trash-groups"') expect(app).toContain('class="trash-groups"')
expect(css).toContain('.list-page-context{width:min(100%,630px);margin:0 auto 0;display:grid;grid-template-columns:minmax(0,1fr) auto;align-items:start;gap:0 16px}') expect(css).toContain('.list-page-context{width:min(100%,630px);margin:0 auto 0;display:grid;grid-template-columns:minmax(0,1fr) auto;align-items:start;gap:0 16px}')
expect(css).toContain('.list-page-title{margin:0;font-size:34px;line-height:1.15;font-weight:700;letter-spacing:-.035em}') expect(css).toContain('.list-page-title{margin:0;font-size:34px;line-height:1.15;font-weight:700;letter-spacing:-.035em}')
expect(css).toContain('.list-page-summary{margin:8px 0 18px;color:var(--text-secondary);font-size:13px}') expect(css).toContain('.list-page-summary{margin:var(--space-8) 0 var(--space-18);color:var(--text-secondary);font-size:13px}')
expect(css).toContain('@media(min-width:1440px){main.list-main>.list-page-context') expect(css).toContain('@media(min-width:1440px){main.list-main>.list-page-context')
expect(css).toContain('@media(max-width:720px){main.list-main{padding-left:29px;padding-right:29px}main.list-main>.list-page-context,main.list-main>.list-section-heading,main.list-main>.task-list,main.list-main>.list-page-meta,main.list-main>.pager,main.list-main>.mvp-view{width:100%}') expect(css).toContain('@media(max-width:720px){main.list-main{padding-left:var(--space-28);padding-right:var(--space-28)}main.list-main>.list-page-context,main.list-main>.list-section-heading,main.list-main>.task-list,main.list-main>.list-page-meta,main.list-main>.pager,main.list-main>.mvp-view{width:100%}')
expect(css).toContain('@media(min-width:721px) and (max-width:1439px){main.list-main>.list-page-context,main.list-main>.list-section-heading,main.list-main>.task-list,main.list-main>.list-page-meta,main.list-main>.pager,main.list-main>.mvp-view{width:min(100%,630px)}') expect(css).toContain('@media(min-width:721px) and (max-width:1439px){main.list-main>.list-page-context,main.list-main>.list-section-heading,main.list-main>.task-list,main.list-main>.list-page-meta,main.list-main>.pager,main.list-main>.mvp-view{width:min(100%,630px)}')
expect(css).not.toContain('.list-search-reveal{') expect(css).not.toContain('.list-search-reveal{')
expect(css).toContain('@media(max-width:720px){') expect(css).toContain('@media(max-width:720px){')
@@ -139,10 +139,10 @@ describe('approved five-detail polish', () => {
expect(css).toContain('.habit-section-heading{margin-top:0}') expect(css).toContain('.habit-section-heading{margin-top:0}')
expect(css).toContain('main.list-main>.mvp-view{gap:0}') expect(css).toContain('main.list-main>.mvp-view{gap:0}')
expect(css).toContain('main.list-main>.mvp-view>.habit-section-heading{width:100%}') expect(css).toContain('main.list-main>.mvp-view>.habit-section-heading{width:100%}')
expect(css).toContain('main.list-main>.mvp-view>.habit-archive-section{margin-top:24px;padding-top:12px;border-top:1px solid var(--border-hairline)}') expect(css).toContain('main.list-main>.mvp-view>.habit-archive-section{margin-top:var(--space-24);padding-top:var(--space-12);border-top:1px solid var(--border-hairline)}')
expect(css).toContain('.habit-archive-section{display:grid;gap:10px}') expect(css).toContain('.habit-archive-section{display:grid;gap:10px}')
expect(css).toContain('.habit-archive-toggle{width:100%;min-height:44px;display:grid;grid-template-columns:28px minmax(0,1fr) 28px;align-items:center;gap:8px;padding:0 4px;border:0;background:transparent;box-shadow:none') expect(css).toContain('.habit-archive-toggle{width:100%;min-height:44px;display:grid;grid-template-columns:28px minmax(0,1fr) 28px;align-items:center;gap:8px;padding:0 var(--space-4);border:0;background:transparent;box-shadow:none')
expect(css).toContain('.archived-habits{margin-top:2px;') expect(css).toContain('.archived-habits{margin-top:var(--space-2);')
}) })
it('uses one 58px plain-list contract for active task and habit rows', () => { it('uses one 58px plain-list contract for active task and habit rows', () => {
@@ -159,7 +159,7 @@ describe('approved five-detail polish', () => {
expect(css).toContain('.habit-row{grid-template-columns:44px minmax(0,1fr) auto;grid-template-rows:18px 4px;align-content:center;row-gap:5px}') expect(css).toContain('.habit-row{grid-template-columns:44px minmax(0,1fr) auto;grid-template-rows:18px 4px;align-content:center;row-gap:5px}')
expect(css).toContain('.habit-row:has(>.habit-progress-bar)>.habit-main,.habit-row:has(>.habit-progress-bar)>.habit-progress-bar{transform:translateY(3px)}') expect(css).toContain('.habit-row:has(>.habit-progress-bar)>.habit-main,.habit-row:has(>.habit-progress-bar)>.habit-progress-bar{transform:translateY(3px)}')
expect(css).toContain('.task-main strong,.habit-name{display:block;min-width:0;font-size:15px;font-weight:400;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}') expect(css).toContain('.task-main strong,.habit-name{display:block;min-width:0;font-size:15px;font-weight:400;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}')
expect(css).toContain('.task-tail,.habit-row-meta{min-width:0;max-width:132px;padding-left:12px;font-size:12px;font-weight:400;white-space:nowrap;text-align:right;overflow:hidden;text-overflow:ellipsis}') expect(css).toContain('.task-tail,.habit-row-meta{min-width:0;max-width:132px;padding-left:var(--space-12);font-size:12px;font-weight:400;white-space:nowrap;text-align:right;overflow:hidden;text-overflow:ellipsis}')
expect(css).toContain('.task-check{width:44px;') expect(css).toContain('.task-check{width:44px;')
expect(css).toContain('.plain-list .habit-check{margin-left:0}') expect(css).toContain('.plain-list .habit-check{margin-left:0}')
expect(memoCss).toContain('.memo-row{width:100%;height:74px;min-height:74px;max-height:76px;') expect(memoCss).toContain('.memo-row{width:100%;height:74px;min-height:74px;max-height:76px;')