fix: align desktop habit detail with task pane
ci / gitleaks (push) Successful in 7s
ci / docker (push) Successful in 3m33s

This commit is contained in:
2026-09-20 10:52:39 +08:00
parent 5a3620bf8a
commit 3a23c6cd44
6 changed files with 70 additions and 18 deletions
+28 -4
View File
@@ -61,9 +61,24 @@ test('habit detail paper flow is responsive, ordered, scrollable, and preserves
await openHabits(page) await openHabits(page)
await expectNoHorizontalOverflow(page) await expectNoHorizontalOverflow(page)
const shell = page.locator('.shell')
const main = page.locator('main')
const mainBeforeDetail = await main.boundingBox()
const numericOpener = page.locator('.habit-row').filter({ hasText: numericName }).getByRole('button', { name: `查看习惯详情:${numericName}` }) const numericOpener = page.locator('.habit-row').filter({ hasText: numericName }).getByRole('button', { name: `查看习惯详情:${numericName}` })
await numericOpener.click() await numericOpener.click()
let dialog = page.getByRole('dialog', { name: numericName }) let dialog = page.getByRole('dialog', { name: numericName })
await expect(shell).toHaveClass(/detail-open/)
if ((page.viewportSize()?.width ?? 0) > 930) {
const [mainAfterDetail, detailBox] = await Promise.all([main.boundingBox(), dialog.boundingBox()])
expect(mainBeforeDetail).not.toBeNull()
expect(mainAfterDetail).not.toBeNull()
expect(detailBox).not.toBeNull()
expect(mainBeforeDetail!.width - mainAfterDetail!.width).toBeCloseTo(350, 0)
expect(detailBox!.width).toBeCloseTo(350, 0)
expect(detailBox!.x).toBeCloseTo(mainAfterDetail!.x + mainAfterDetail!.width, 0)
expect(detailBox!.height).toBeCloseTo(page.viewportSize()!.height, 0)
}
await expect(dialog.locator('.habit-detail-hero')).toContainText('68 / 100') await expect(dialog.locator('.habit-detail-hero')).toContainText('68 / 100')
await expect(dialog.locator('.habit-detail-progress-row')).toContainText('68%') await expect(dialog.locator('.habit-detail-progress-row')).toContainText('68%')
await expect(dialog.locator('.habit-detail-archive-note')).toHaveCount(0) await expect(dialog.locator('.habit-detail-archive-note')).toHaveCount(0)
@@ -135,8 +150,10 @@ test('habit detail paper flow is responsive, ordered, scrollable, and preserves
const restoreGate = new Promise<void>(resolve => { releaseRestore = resolve }) const restoreGate = new Promise<void>(resolve => { releaseRestore = resolve })
await page.route(`**/api/v1/habits/${archived.id}/restore`, async route => { await restoreGate; await route.continue() }) await page.route(`**/api/v1/habits/${archived.id}/restore`, async route => { await restoreGate; await route.continue() })
await dialog.getByRole('button', { name: '恢复习惯' }).click() await dialog.getByRole('button', { name: '恢复习惯' }).click()
const mask = page.locator('.app-sheet-mask').filter({ has: dialog }) const overlay = (page.viewportSize()?.width ?? 0) <= 930
await expect(mask).toHaveAttribute('aria-busy', 'true') ? page.locator('.app-sheet-mask').filter({ has: dialog })
: dialog
await expect(overlay).toHaveAttribute('aria-busy', 'true')
const closeButton = dialog.getByRole('button', { name: '关闭习惯详情' }) const closeButton = dialog.getByRole('button', { name: '关闭习惯详情' })
const deleteButton = dialog.getByRole('button', { name: '永久删除' }) const deleteButton = dialog.getByRole('button', { name: '永久删除' })
const restoreButton = dialog.getByRole('button', { name: '恢复习惯' }) const restoreButton = dialog.getByRole('button', { name: '恢复习惯' })
@@ -145,11 +162,18 @@ test('habit detail paper flow is responsive, ordered, scrollable, and preserves
await expect(restoreButton).toBeDisabled() await expect(restoreButton).toBeDisabled()
await closeButton.click({ force: true }) await closeButton.click({ force: true })
await expect(dialog).toBeVisible() await expect(dialog).toBeVisible()
await mask.click({ position: { x: 2, y: 2 }, force: true }) if ((page.viewportSize()?.width ?? 0) <= 930) await overlay.click({ position: { x: 2, y: 2 }, force: true })
await expect(dialog).toBeVisible() await expect(dialog).toBeVisible()
await page.keyboard.press('Escape') await page.keyboard.press('Escape')
await expect(dialog).toBeVisible() await expect(dialog).toBeVisible()
// Desktop same-view navigation must force-close a busy detail even though user dismissal stays locked.
if ((page.viewportSize()?.width ?? 0) > 930) {
await page.locator('.sidebar').getByRole('button', { name: '习惯', exact: true }).click()
await expect(dialog).toBeHidden()
await expect(shell).not.toHaveClass(/detail-open/)
}
releaseRestore() releaseRestore()
await expect(dialog).toBeHidden() await expect(dialog).toBeHidden()
await expect(archiveToggle).toBeFocused() await expect(page.locator('.habit-row').filter({ hasText: archivedName })).toBeVisible()
}) })
+10 -4
View File
@@ -168,6 +168,7 @@ const countdownComposer = ref<InstanceType<typeof CountdownPanel> | null>(null)
const memoPanel = ref<InstanceType<typeof MemoPanel> | null>(null) const memoPanel = ref<InstanceType<typeof MemoPanel> | null>(null)
const memoTrash = ref(false) const memoTrash = ref(false)
const memoDetailOpen = ref(false) const memoDetailOpen = ref(false)
const habitDetailOpen = ref(false)
const compactLayout = ref(window.innerWidth <= 930) const compactLayout = ref(window.innerWidth <= 930)
const memoShellState = computed(() => deriveMemoShellState({ view: activeView.value, detailOpen: memoDetailOpen.value, compact: compactLayout.value, trash: memoTrash.value })) const memoShellState = computed(() => deriveMemoShellState({ view: activeView.value, detailOpen: memoDetailOpen.value, compact: compactLayout.value, trash: memoTrash.value }))
const memoBackgroundInert = computed(() => memoShellState.value.backgroundInert) const memoBackgroundInert = computed(() => memoShellState.value.backgroundInert)
@@ -696,7 +697,7 @@ async function switchView(view: View, listId?: string) {
if (listId) activeList.value = listId if (listId) activeList.value = listId
writeStoredNavigation(window.localStorage, NAVIGATION_STORAGE_KEY, view, activeList.value) writeStoredNavigation(window.localStorage, NAVIGATION_STORAGE_KEY, view, activeList.value)
page.value = 1 page.value = 1
selectedTask.value = null; taskSelectionGeneration.value += 1; mobileSidebar.value = false; mobileDetail.value = false; taskComposeGeneration.value += 1; taskComposeOpen.value = false; sidebarCreateOpen.value = false; sidebarAction.value = null habitComposer.value?.closeHabitDetail(true); selectedTask.value = null; habitDetailOpen.value = false; taskSelectionGeneration.value += 1; mobileSidebar.value = false; mobileDetail.value = false; taskComposeGeneration.value += 1; taskComposeOpen.value = false; sidebarCreateOpen.value = false; sidebarAction.value = null
if (view !== 'memos') memoDetailOpen.value = false if (view !== 'memos') memoDetailOpen.value = false
if (view === 'trash') await loadTrash() if (view === 'trash') await loadTrash()
else if (view === 'today') await loadTodayView() else if (view === 'today') await loadTodayView()
@@ -1159,6 +1160,10 @@ function closeTaskDetail() {
mobileDetail.value = false mobileDetail.value = false
selectedTask.value = null selectedTask.value = null
} }
function handleHabitDetail(open: boolean) {
if (open) closeTaskDetail()
habitDetailOpen.value = open
}
function addSelectedDueTime() { function addSelectedDueTime() {
selectedDueHasTime.value = true selectedDueHasTime.value = true
nextTick(() => { nextTick(() => {
@@ -1172,6 +1177,7 @@ function clearSelectedDueDate() {
selectedTaskRepeat.value = 'none' selectedTaskRepeat.value = 'none'
} }
function selectTask(task: Task) { function selectTask(task: Task) {
habitComposer.value?.closeHabitDetail(true)
taskSelectionGeneration.value += 1 taskSelectionGeneration.value += 1
selectedTask.value = { ...task, subtasks: task.subtasks ? [...task.subtasks] : [] } selectedTask.value = { ...task, subtasks: task.subtasks ? [...task.subtasks] : [] }
const due = parseTaskDueDraft(task.due_at, task.due_has_time) const due = parseTaskDueDraft(task.due_at, task.due_has_time)
@@ -1521,7 +1527,7 @@ onUnmounted(() => {
<p v-if="initialized" class="auth-footnote">登录后继续你的清单</p> <p v-if="initialized" class="auth-footnote">登录后继续你的清单</p>
</form> </form>
</div> </div>
<div v-else class="shell" :class="{ 'today-active': activeView==='today', 'sidebar-collapsed': sidebarCollapsed, 'detail-open': Boolean(selectedTask), 'memo-detail-open': activeView==='memos' && memoDetailOpen, 'mobile-sidebar-open': mobileSidebar }"> <div v-else class="shell" :class="{ 'today-active': activeView==='today', 'sidebar-collapsed': sidebarCollapsed, 'detail-open': Boolean(selectedTask) || habitDetailOpen, 'memo-detail-open': activeView==='memos' && memoDetailOpen, 'mobile-sidebar-open': mobileSidebar }">
<div v-if="mobileSidebar || mobileDetail" class="scrim" @click="mobileSidebar=false;mobileDetail=false" /> <div v-if="mobileSidebar || mobileDetail" class="scrim" @click="mobileSidebar=false;mobileDetail=false" />
<aside class="sidebar" :inert="memoBackgroundInert ? true : undefined" :class="{ open: mobileSidebar }" @keydown.esc="sidebarCreateOpen=false;closeArchivedListAction();closeSidebarAction()"> <aside class="sidebar" :inert="memoBackgroundInert ? true : undefined" :class="{ open: mobileSidebar }" @keydown.esc="sidebarCreateOpen=false;closeArchivedListAction();closeSidebarAction()">
<div class="brand-row"><div class="brand small brand-lockup"><img class="brand-logo" src="/dodo-logo.svg" alt=""><span class="brand-wordmark">dodo</span></div><button class="icon mobile-only" aria-label="关闭菜单" @click="mobileSidebar=false"><X /></button></div> <div class="brand-row"><div class="brand small brand-lockup"><img class="brand-logo" src="/dodo-logo.svg" alt=""><span class="brand-wordmark">dodo</span></div><button class="icon mobile-only" aria-label="关闭菜单" @click="mobileSidebar=false"><X /></button></div>
@@ -1599,7 +1605,7 @@ onUnmounted(() => {
<div v-if="!['today','tasks','upcoming','habits','settings'].includes(activeView)" class="topbar-title"><h1 :title="activeName">{{ activeName }}</h1></div> <div v-if="!['today','tasks','upcoming','habits','settings'].includes(activeView)" class="topbar-title"><h1 :title="activeName">{{ activeName }}</h1></div>
</header> </header>
<template v-if="['habits','settings'].includes(activeView)"> <template v-if="['habits','settings'].includes(activeView)">
<MvpPanel ref="habitComposer" :key="activeView" :view="activeView as 'habits'|'settings'" :show-completed="showCompleted" @update:show-completed="showCompleted=$event" @changed="refreshAll" @notice="toast" @logout="completeLogout" /> <MvpPanel ref="habitComposer" :key="activeView" :view="activeView as 'habits'|'settings'" :show-completed="showCompleted" :compact-layout="compactLayout" @update:show-completed="showCompleted=$event" @detail="handleHabitDetail" @changed="refreshAll" @notice="toast" @logout="completeLogout" />
</template> </template>
<MemoPanel v-else-if="activeView==='memos'" ref="memoPanel" :request="api" @scope="memoTrash=$event==='trash'" @detail="memoDetailOpen=$event" @notice="toast" /> <MemoPanel v-else-if="activeView==='memos'" ref="memoPanel" :request="api" @scope="memoTrash=$event==='trash'" @detail="memoDetailOpen=$event" @notice="toast" />
<CountdownPanel ref="countdownComposer" v-else-if="activeView==='countdowns'" @notice="toast" /> <CountdownPanel ref="countdownComposer" v-else-if="activeView==='countdowns'" @notice="toast" />
@@ -1645,7 +1651,7 @@ onUnmounted(() => {
<div v-if="activeView==='today'" class="today-habits-section today-section-anchor"> <div v-if="activeView==='today'" class="today-habits-section today-section-anchor">
<button id="today-habits-heading" class="today-section-toggle" type="button" :aria-expanded="!todaySectionCollapse.habits" aria-controls="today-habits" @click="toggleTodaySection('habits')"><span class="today-section-title">习惯</span><span class="today-section-summary">{{todayHabitTotal}}</span><span class="today-section-chevron" aria-hidden="true">{{ todaySectionCollapse.habits ? '' : '⌄' }}</span></button> <button id="today-habits-heading" class="today-section-toggle" type="button" :aria-expanded="!todaySectionCollapse.habits" aria-controls="today-habits" @click="toggleTodaySection('habits')"><span class="today-section-title">习惯</span><span class="today-section-summary">{{todayHabitTotal}}</span><span class="today-section-chevron" aria-hidden="true">{{ todaySectionCollapse.habits ? '' : '⌄' }}</span></button>
<div v-show="!todaySectionCollapse.habits" id="today-habits" role="region" aria-labelledby="today-habits-heading"> <div v-show="!todaySectionCollapse.habits" id="today-habits" role="region" aria-labelledby="today-habits-heading">
<MvpPanel ref="habitComposer" view="today-habits" :show-completed="showCompleted" @changed="refreshAll" @notice="toast" @summary="updateTodayHabitSummary" /> <MvpPanel ref="habitComposer" view="today-habits" :show-completed="showCompleted" :compact-layout="compactLayout" @detail="handleHabitDetail" @changed="refreshAll" @notice="toast" @summary="updateTodayHabitSummary" />
</div> </div>
</div> </div>
</template> </template>
+8 -6
View File
@@ -16,12 +16,13 @@ type HabitCell = { day: string; scheduled?: boolean; paused?: boolean; value: nu
type HabitLog = HabitHistoryLog type HabitLog = HabitHistoryLog
type Habit = { id: string; name: string; kind?: string; target?: number; max_value?: number | null; schedule_type?: HabitFormValues['schedule_type']; weekdays?: number[] | null; month_days?: number[] | null; interval_days?: number | null; start_date?: string; archived_at?: string | null; position?: number; unit?: string; cells?: HabitCell[]; stats?: Record<string, number> } type Habit = { id: string; name: string; kind?: string; target?: number; max_value?: number | null; schedule_type?: HabitFormValues['schedule_type']; weekdays?: number[] | null; month_days?: number[] | null; interval_days?: number | null; start_date?: string; archived_at?: string | null; position?: number; unit?: string; cells?: HabitCell[]; stats?: Record<string, number> }
type Session = { id: string; created_at?: string; last_seen_at?: string; current?: boolean; user_agent?: string } type Session = { id: string; created_at?: string; last_seen_at?: string; current?: boolean; user_agent?: string }
const props = defineProps<{ view: View; showCompleted: boolean }>() const props = defineProps<{ view: View; showCompleted: boolean; compactLayout: boolean }>()
const emit = defineEmits<{ const emit = defineEmits<{
changed: [] changed: []
notice: [message: string] notice: [message: string]
summary: [value: { total: number; completed: number }] summary: [value: { total: number; completed: number }]
logout: [] logout: []
detail: [open: boolean]
'update:showCompleted': [value: boolean] 'update:showCompleted': [value: boolean]
}>() }>()
const habits = ref<Habit[]>([]) const habits = ref<Habit[]>([])
@@ -138,6 +139,7 @@ watch(() => props.view, () => {
completionExitingHabitIds.value = new Set() completionExitingHabitIds.value = new Set()
}) })
watch(todayHabitSummary, (value) => emit('summary', value), { immediate: true }) watch(todayHabitSummary, (value) => emit('summary', value), { immediate: true })
watch(selectedHabit, (habit) => emit('detail', Boolean(habit)))
watch(habitReorderAvailable, (available) => { watch(habitReorderAvailable, (available) => {
if (!available) habitReorderMode.value = false if (!available) habitReorderMode.value = false
}) })
@@ -532,8 +534,8 @@ function openHabitDetail(h: Habit, opener?: HTMLElement | null) {
selectedHabit.value = h selectedHabit.value = h
void loadHabitHistory(true) void loadHabitHistory(true)
} }
function closeHabitDetail() { function closeHabitDetail(force = false) {
if (busy.value) return if (busy.value && !force) return
habitHistoryRequest += 1 habitHistoryRequest += 1
selectedHabit.value = null selectedHabit.value = null
habitHistory.value = [] habitHistory.value = []
@@ -547,7 +549,7 @@ function closeHabitDetail() {
else habitArchiveToggle.value?.focus() else habitArchiveToggle.value?.focus()
}) })
} }
defineExpose({ openHabitComposer }) defineExpose({ openHabitComposer, closeHabitDetail })
async function archiveHabit(h: Habit) { async function archiveHabit(h: Habit) {
if (busy.value) return if (busy.value) return
if (!(await confirmAction(`归档习惯“${h.name}”?`, '历史打卡记录会保留。'))) return if (!(await confirmAction(`归档习惯“${h.name}”?`, '历史打卡记录会保留。'))) return
@@ -882,10 +884,10 @@ onBeforeUnmount(() => {
<button v-for="h in archiveFlags.list ? archivedHabits : []" :key="h.id" class="archived-habit-row" type="button" @click="openHabitDetail(h, $event.currentTarget as HTMLElement)"><span><b>{{ h.name }}</b><small>{{ formatArchivedAt(h.archived_at) }}</small></span><ChevronRight aria-hidden="true"/></button> <button v-for="h in archiveFlags.list ? archivedHabits : []" :key="h.id" class="archived-habit-row" type="button" @click="openHabitDetail(h, $event.currentTarget as HTMLElement)"><span><b>{{ h.name }}</b><small>{{ formatArchivedAt(h.archived_at) }}</small></span><ChevronRight aria-hidden="true"/></button>
</div> </div>
</section> </section>
<AppSheet :open="Boolean(selectedHabit)" variant="detail" panel-class="habit-detail-sheet" title-id="habit-detail-title" initial-focus="button[aria-label='关闭习惯详情']" :busy="busy" @close="closeHabitDetail"> <AppSheet :open="Boolean(selectedHabit)" :modal="compactLayout" inline-target=".shell" variant="detail" panel-class="habit-detail-sheet" title-id="habit-detail-title" initial-focus="button[aria-label='关闭习惯详情']" :busy="busy" :close-on-scrim="compactLayout" @close="closeHabitDetail">
<template v-if="selectedHabit"> <template v-if="selectedHabit">
<p v-if="selectedHabit.archived_at" class="habit-detail-archive-note" role="status">此习惯已归档历史记录仍完整保留</p> <p v-if="selectedHabit.archived_at" class="habit-detail-archive-note" role="status">此习惯已归档历史记录仍完整保留</p>
<header class="app-sheet__header habit-detail-header"><div><h3 id="habit-detail-title">{{ selectedHabit.name }}</h3></div><button type="button" aria-label="关闭习惯详情" :disabled="busy" @click="closeHabitDetail"><X/></button></header> <header class="app-sheet__header habit-detail-header"><div><h3 id="habit-detail-title">{{ selectedHabit.name }}</h3></div><button type="button" aria-label="关闭习惯详情" :disabled="busy" @click="closeHabitDetail()"><X/></button></header>
<div class="app-sheet__body habit-detail-body"> <div class="app-sheet__body habit-detail-body">
<template v-if="selectedHabitDetailProgress"> <template v-if="selectedHabitDetailProgress">
<section class="habit-detail-hero" aria-labelledby="habit-detail-today-label"> <section class="habit-detail-hero" aria-labelledby="habit-detail-today-label">
+7 -1
View File
@@ -13,7 +13,8 @@ const props = withDefaults(defineProps<{
closeOnScrim?: boolean closeOnScrim?: boolean
variant?: 'create' | 'detail' | 'actions' variant?: 'create' | 'detail' | 'actions'
panelClass?: string panelClass?: string
}>(), { busy: false, modal: true, closeOnScrim: true, variant: 'detail', panelClass: '' }) inlineTarget?: string
}>(), { busy: false, modal: true, closeOnScrim: true, variant: 'detail', panelClass: '', inlineTarget: '' })
defineOptions({ inheritAttrs: false }) defineOptions({ inheritAttrs: false })
const emit = defineEmits<{ close: [] }>() const emit = defineEmits<{ close: [] }>()
const panel = ref<HTMLElement | null>(null) const panel = ref<HTMLElement | null>(null)
@@ -79,6 +80,11 @@ onBeforeUnmount(deactivate)
</component> </component>
</div> </div>
</Teleport> </Teleport>
<Teleport v-else-if="inlineTarget" :to="inlineTarget">
<component v-if="open" :is="$attrs.onSubmit ? 'form' : 'section'" ref="panel" class="app-sheet" :class="[`app-sheet--${variant}`, panelClass]" role="dialog" :aria-labelledby="titleId" :aria-describedby="descriptionId" :aria-label="label" :aria-busy="busy || undefined" v-bind="$attrs">
<slot />
</component>
</Teleport>
<component v-else-if="open" :is="$attrs.onSubmit ? 'form' : 'section'" ref="panel" class="app-sheet" :class="[`app-sheet--${variant}`, panelClass]" role="dialog" :aria-labelledby="titleId" :aria-describedby="descriptionId" :aria-label="label" v-bind="$attrs"> <component v-else-if="open" :is="$attrs.onSubmit ? 'form' : 'section'" ref="panel" class="app-sheet" :class="[`app-sheet--${variant}`, panelClass]" role="dialog" :aria-labelledby="titleId" :aria-describedby="descriptionId" :aria-label="label" v-bind="$attrs">
<slot /> <slot />
</component> </component>
+1
View File
@@ -234,5 +234,6 @@ main:has(>.mvp-view .settings-sections) .settings-data>.inline-error{margin:14px
/* Approved habit detail 01: paper flow. */ /* Approved habit detail 01: paper flow. */
.habit-detail-sheet{width:min(500px,100%);max-height:min(88dvh,760px);padding:0;gap:0;display:flex;flex-direction:column;background:#fffdf8;overflow:hidden}.habit-detail-archive-note{order:-1;flex:0 0 auto;margin:0;padding:10px 20px;border:0;border-bottom:1px solid #e8b7aa;background:#fff5f2;color:var(--danger);font-size:12px;line-height:1.5}.habit-detail-header{height:64px;flex:0 0 64px;padding:0 20px}.habit-detail-header h3{font-size:18px}.habit-detail-body{flex:1 1 auto;gap:0;padding:18px 20px;overflow-y:auto;overflow-x:hidden}.habit-detail-hero{padding:4px 0 18px;border-bottom:1px solid #e4dbcf}.habit-detail-hero strong{display:block;color:var(--text-primary);font-size:28px;line-height:1.15;letter-spacing:-.04em;font-variant-numeric:tabular-nums}.habit-detail-hero small{display:block;margin-top:6px;color:var(--muted);font-size:12px}.habit-detail-progress-row{min-height:66px;display:grid;grid-template-columns:minmax(0,1fr) auto;align-items:center;gap:14px;border-bottom:1px solid #e4dbcf}.habit-detail-progress-row span{font-size:12px;font-weight:700}.habit-detail-progress-row small{display:block;margin-top:3px;color:var(--muted);font-size:11px}.habit-detail-progress-row strong{color:var(--success);font-size:20px}.habit-detail-meta{margin:0 0 14px}.habit-detail-meta-row{min-height:52px;display:grid;grid-template-columns:86px minmax(0,1fr);align-items:center;gap:12px;border-bottom:1px solid #e4dbcf;font-size:13px}.habit-detail-meta-row dt{color:var(--muted);font-size:11px;font-weight:700}.habit-detail-meta-row dd{min-width:0;margin:0;text-align:right;overflow-wrap:anywhere}.habit-history__header{min-height:42px;border-bottom:1px solid #e4dbcf}.habit-history__list{border-top:0}.habit-detail-active-actions{min-height:68px;justify-content:space-between;padding:12px 20px calc(12px + env(safe-area-inset-bottom))}.habit-detail-archive-button{min-height:40px;display:inline-flex;align-items:center;gap:6px;padding:0;border:0;background:transparent;color:var(--danger);font-size:13px;font-weight:700}.habit-detail-archive-button svg{width:16px;height:16px}.habit-detail-edit-button{min-width:100px}.habit-detail-archived-actions{min-height:68px;padding:12px 20px calc(12px + env(safe-area-inset-bottom));background:#fffdf8;border-top:1px solid #e4dbcf}.habit-detail-archived-actions .habit-delete-button{display:inline-flex;align-items:center;justify-content:center;gap:6px;border:0;background:transparent;color:var(--danger)} .habit-detail-sheet{width:min(500px,100%);max-height:min(88dvh,760px);padding:0;gap:0;display:flex;flex-direction:column;background:#fffdf8;overflow:hidden}.habit-detail-archive-note{order:-1;flex:0 0 auto;margin:0;padding:10px 20px;border:0;border-bottom:1px solid #e8b7aa;background:#fff5f2;color:var(--danger);font-size:12px;line-height:1.5}.habit-detail-header{height:64px;flex:0 0 64px;padding:0 20px}.habit-detail-header h3{font-size:18px}.habit-detail-body{flex:1 1 auto;gap:0;padding:18px 20px;overflow-y:auto;overflow-x:hidden}.habit-detail-hero{padding:4px 0 18px;border-bottom:1px solid #e4dbcf}.habit-detail-hero strong{display:block;color:var(--text-primary);font-size:28px;line-height:1.15;letter-spacing:-.04em;font-variant-numeric:tabular-nums}.habit-detail-hero small{display:block;margin-top:6px;color:var(--muted);font-size:12px}.habit-detail-progress-row{min-height:66px;display:grid;grid-template-columns:minmax(0,1fr) auto;align-items:center;gap:14px;border-bottom:1px solid #e4dbcf}.habit-detail-progress-row span{font-size:12px;font-weight:700}.habit-detail-progress-row small{display:block;margin-top:3px;color:var(--muted);font-size:11px}.habit-detail-progress-row strong{color:var(--success);font-size:20px}.habit-detail-meta{margin:0 0 14px}.habit-detail-meta-row{min-height:52px;display:grid;grid-template-columns:86px minmax(0,1fr);align-items:center;gap:12px;border-bottom:1px solid #e4dbcf;font-size:13px}.habit-detail-meta-row dt{color:var(--muted);font-size:11px;font-weight:700}.habit-detail-meta-row dd{min-width:0;margin:0;text-align:right;overflow-wrap:anywhere}.habit-history__header{min-height:42px;border-bottom:1px solid #e4dbcf}.habit-history__list{border-top:0}.habit-detail-active-actions{min-height:68px;justify-content:space-between;padding:12px 20px calc(12px + env(safe-area-inset-bottom))}.habit-detail-archive-button{min-height:40px;display:inline-flex;align-items:center;gap:6px;padding:0;border:0;background:transparent;color:var(--danger);font-size:13px;font-weight:700}.habit-detail-archive-button svg{width:16px;height:16px}.habit-detail-edit-button{min-width:100px}.habit-detail-archived-actions{min-height:68px;padding:12px 20px calc(12px + env(safe-area-inset-bottom));background:#fffdf8;border-top:1px solid #e4dbcf}.habit-detail-archived-actions .habit-delete-button{display:inline-flex;align-items:center;justify-content:center;gap:6px;border:0;background:transparent;color:var(--danger)}
@media(min-width:931px){.habit-detail-sheet{width:350px;height:100vh;max-height:none;border-radius:0;border-top:0;border-right:0;border-bottom:0;box-shadow:inset 1px 0 0 #fff}}
@media(max-width:930px){.habit-detail-sheet.app-sheet--detail{width:100%;max-width:100%}.habit-detail-sheet :is(.habit-detail-archive-button,.habit-detail-edit-button,.habit-restore-button,.habit-delete-button,.habit-history__more){min-height:44px}} @media(max-width:930px){.habit-detail-sheet.app-sheet--detail{width:100%;max-width:100%}.habit-detail-sheet :is(.habit-detail-archive-button,.habit-detail-edit-button,.habit-restore-button,.habit-delete-button,.habit-history__more){min-height:44px}}
@media(max-width:390px){.habit-detail-body{padding:16px}.habit-detail-header{padding:0 16px}.habit-detail-active-actions,.habit-detail-archived-actions{padding-left:16px;padding-right:16px}.habit-detail-meta-row{grid-template-columns:78px minmax(0,1fr)}} @media(max-width:390px){.habit-detail-body{padding:16px}.habit-detail-header{padding:0 16px}.habit-detail-active-actions,.habit-detail-archived-actions{padding-left:16px;padding-right:16px}.habit-detail-meta-row{grid-template-columns:78px minmax(0,1fr)}}
+16 -3
View File
@@ -9,6 +9,7 @@ const countdownPanel = readFileSync('src/CountdownPanel.vue', 'utf8')
const floatingAdd = readFileSync('src/components/FloatingAddButton.vue', 'utf8') const floatingAdd = readFileSync('src/components/FloatingAddButton.vue', 'utf8')
const completedFilterPill = readFileSync('src/components/CompletedFilterPill.vue', 'utf8') const completedFilterPill = readFileSync('src/components/CompletedFilterPill.vue', 'utf8')
const taskDueDisplay = readFileSync('src/components/TaskDueDisplay.vue', 'utf8') const taskDueDisplay = readFileSync('src/components/TaskDueDisplay.vue', 'utf8')
const appSheet = readFileSync('src/components/AppSheet.vue', 'utf8')
describe('unified task due display', () => { describe('unified task due display', () => {
it('uses the shared display for overdue and ordinary parent task rows', () => { it('uses the shared display for overdue and ordinary parent task rows', () => {
@@ -1463,17 +1464,29 @@ describe('unified floating add interaction', () => {
}) })
}) })
describe('desktop task detail disclosure', () => { describe('desktop task and habit detail disclosure', () => {
it('gives the task list the full remaining width until a task is selected', () => { it('gives the list the full remaining width until a task or habit is selected', () => {
expect(app).toContain("'detail-open': Boolean(selectedTask)") expect(app).toContain("'detail-open': Boolean(selectedTask) || habitDetailOpen")
expect(app).toContain('<AppSheet v-if="selectedTask" :open="true" :modal="compactLayout"') expect(app).toContain('<AppSheet v-if="selectedTask" :open="true" :modal="compactLayout"')
expect(app).toContain('panel-class="detail"') expect(app).toContain('panel-class="detail"')
expect(app).toContain(':compact-layout="compactLayout"')
expect(app).toContain('@detail="handleHabitDetail"')
expect(app).toContain('function handleHabitDetail(open: boolean)')
expect(app).toContain('if (open) closeTaskDetail()')
expect(app).toContain('habitComposer.value?.closeHabitDetail(true)')
expect(mvpPanel).toContain('function closeHabitDetail(force = false)')
expect(mvpPanel).toContain('if (busy.value && !force) return')
expect(mvpPanel).toContain(':modal="compactLayout"')
expect(mvpPanel).toContain('inline-target=".shell"')
expect(appSheet).toContain('<Teleport v-else-if="inlineTarget" :to="inlineTarget">')
expect(mvpPanel).toContain("watch(selectedHabit, (habit) => emit('detail', Boolean(habit)))")
expect(app).toContain('@click="closeTaskDetail"') expect(app).toContain('@click="closeTaskDetail"')
expect(app).toContain('function closeTaskDetail()') expect(app).toContain('function closeTaskDetail()')
expect(app).not.toContain('<div v-else class="paper">') expect(app).not.toContain('<div v-else class="paper">')
expect(css).toContain('.shell{height:100vh;display:grid;grid-template-columns:236px minmax(430px,1fr) 0;') expect(css).toContain('.shell{height:100vh;display:grid;grid-template-columns:236px minmax(430px,1fr) 0;')
expect(css).toContain('.shell.detail-open{grid-template-columns:236px minmax(430px,1fr) 350px}') expect(css).toContain('.shell.detail-open{grid-template-columns:236px minmax(430px,1fr) 350px}')
expect(css).toContain('.shell.sidebar-collapsed.detail-open{grid-template-columns:0 minmax(430px,1fr) 350px}') expect(css).toContain('.shell.sidebar-collapsed.detail-open{grid-template-columns:0 minmax(430px,1fr) 350px}')
expect(css).toContain('@media(min-width:931px){.habit-detail-sheet{width:350px;height:100vh;max-height:none;border-radius:0;')
expect(css).toContain('transition:grid-template-columns .22s ease') expect(css).toContain('transition:grid-template-columns .22s ease')
}) })
}) })