feat: add Today environment summary
ci / gitleaks (push) Successful in 51s
ci / docker (push) Successful in 4m34s

This commit is contained in:
2026-09-16 12:23:59 +08:00
parent ed6d999ffb
commit 0a4d5f2f05
12 changed files with 1405 additions and 7 deletions
+40 -1
View File
@@ -21,7 +21,8 @@ import FloatingAddButton from './components/FloatingAddButton.vue'
import CompletedFilterPill from './components/CompletedFilterPill.vue'
import CalendarPicker from './components/CalendarPicker.vue'
import TaskDueDisplay from './components/TaskDueDisplay.vue'
import { useTaskDueClock } from './lib/task-due-clock'
import TodayEnvironmentStrip, { type TodayEnvironment } from './components/TodayEnvironmentStrip.vue'
import { shanghaiDateKey, useTaskDueClock, watchShanghaiDateRollover } from './lib/task-due-clock'
import { readTodaySectionCollapse, writeTodaySectionCollapse, type TodaySectionCollapse } from './lib/today-section-collapse'
type FolderItem = { id: string; name: string }
@@ -108,6 +109,10 @@ const todayTaskTotal = ref(0)
const todayTaskCompleted = ref(0)
const todayHabitTotal = ref(0)
const todayHabitCompleted = ref(0)
const todayEnvironment = ref<TodayEnvironment | null>(null)
const todayEnvironmentLoading = ref(false)
const todayEnvironmentError = ref(false)
const todayEnvironmentDateKey = ref('')
const totalPages = computed(() => Math.max(1, Math.ceil(totalTasks.value / pageSize)))
const taskReorderAvailable = computed(() => activeView.value === 'tasks' && !query.value && totalPages.value === 1 && taskTree.value.length > 1)
const expandedFolders = ref(new Set<string>())
@@ -157,6 +162,7 @@ const selectedRepeatConfig = ref<TaskRepeatConfig>(defaultRepeatConfig())
const weekdayOptions = [{ value: 'MO', label: '一' }, { value: 'TU', label: '二' }, { value: 'WE', label: '三' }, { value: 'TH', label: '四' }, { value: 'FR', label: '五' }, { value: 'SA', label: '六' }, { value: 'SU', label: '日' }]
let recurrenceLoadToken = 0
let todaySummaryLoadToken = 0
let todayEnvironmentLoadToken = 0
const habitComposer = ref<InstanceType<typeof MvpPanel> | null>(null)
const countdownComposer = ref<InstanceType<typeof CountdownPanel> | null>(null)
const memoPanel = ref<InstanceType<typeof MemoPanel> | null>(null)
@@ -534,6 +540,27 @@ async function loadTodayTaskSummary() {
todayTaskTotal.value = overdue + open + completed
} catch { /* 概览统计失败不阻断今天页 */ }
}
async function loadTodayEnvironment() {
const token = ++todayEnvironmentLoadToken
todayEnvironmentLoading.value = true
todayEnvironmentError.value = false
try {
const data = await api('/today/environment') as TodayEnvironment
if (token !== todayEnvironmentLoadToken || activeView.value !== 'today') return
todayEnvironment.value = data
todayEnvironmentDateKey.value = data.date?.solar_date || shanghaiDateKey()
} catch {
if (token !== todayEnvironmentLoadToken || activeView.value !== 'today') return
todayEnvironmentError.value = true
} finally {
if (token === todayEnvironmentLoadToken && activeView.value === 'today') todayEnvironmentLoading.value = false
}
}
function handleTodayEnvironmentResume() {
if (document.visibilityState === 'hidden' || activeView.value !== 'today') return
if (todayEnvironmentDateKey.value !== shanghaiDateKey()) void loadTodayEnvironment()
}
watchShanghaiDateRollover(taskDueNowMs, handleTodayEnvironmentResume)
async function loadOverdueTasks(request = beginLatestRequest('tasks')) {
const params = new URLSearchParams()
params.set('due_to', isoAtLocalDayOffset(0))
@@ -601,6 +628,7 @@ async function loadAll() {
if (!navigationLoaded.value) await loadNavigation()
if (!isLatestRequest('tasks', request)) return
if (activeView.value === 'today') {
void loadTodayEnvironment()
await startPrimaryWithBackground(
[() => loadTasksPage(request), () => loadOverdueTasks(request)],
loadTodayTaskSummary,
@@ -655,6 +683,10 @@ async function switchView(view: View, listId?: string) {
taskMutationNavigation.value += 1
taskReorderMode.value = false
cancelTaskReorder()
if (view !== 'today') {
++todayEnvironmentLoadToken
todayEnvironmentLoading.value = false
}
activeView.value = view
if (!query.value) mobileSearchOpen.value = false
searchPullDistance.value = 0
@@ -1376,6 +1408,9 @@ onMounted(() => {
document.addEventListener('keydown', handleArchivedListEscape)
document.addEventListener('keydown', handleTaskSearchShortcut)
window.addEventListener('resize', handleViewportResize)
document.addEventListener('visibilitychange', handleTodayEnvironmentResume)
window.addEventListener('focus', handleTodayEnvironmentResume)
window.addEventListener('pageshow', handleTodayEnvironmentResume)
document.addEventListener('scroll', handleArchivedListViewportChange, true)
void bootstrap()
})
@@ -1384,6 +1419,9 @@ onUnmounted(() => {
document.removeEventListener('keydown', handleArchivedListEscape)
document.removeEventListener('keydown', handleTaskSearchShortcut)
window.removeEventListener('resize', handleViewportResize)
document.removeEventListener('visibilitychange', handleTodayEnvironmentResume)
window.removeEventListener('focus', handleTodayEnvironmentResume)
window.removeEventListener('pageshow', handleTodayEnvironmentResume)
document.removeEventListener('scroll', handleArchivedListViewportChange, true)
})
</script>
@@ -1491,6 +1529,7 @@ onUnmounted(() => {
<CountdownPanel ref="countdownComposer" v-else-if="activeView==='countdowns'" @notice="toast" />
<template v-else>
<section v-if="activeView==='today'" class="today-board" aria-label="今日进度">
<TodayEnvironmentStrip :environment="todayEnvironment" :loading="todayEnvironmentLoading" :failed="todayEnvironmentError" />
<button class="today-track today-task-track" type="button" aria-controls="today-tasks" @click="navigateTodaySection('tasks')">
<span class="today-track-head"><strong>任务 {{ todayTaskCompleted }} / {{ todayTaskTotal }}</strong></span>
<span class="today-track-rail" role="progressbar" aria-label="今日任务进度" aria-valuemin="0" :aria-valuemax="todayTaskTotal" :aria-valuenow="todayTaskCompleted"><i class="today-track-fill" :style="{ width: todayTaskProgressPercent }" /></span>
+55
View File
@@ -0,0 +1,55 @@
import { readFileSync } from 'node:fs'
import { describe, expect, it } from 'vitest'
const app = readFileSync('src/App.vue', 'utf8')
const css = readFileSync('src/style.css', 'utf8')
describe('Today environment integration', () => {
it('loads environment independently alongside Today task work', () => {
expect(app).toContain("import TodayEnvironmentStrip, { type TodayEnvironment } from './components/TodayEnvironmentStrip.vue'")
expect(app).toContain('let todayEnvironmentLoadToken = 0')
expect(app).toContain("api('/today/environment')")
expect(app).toContain('void loadTodayEnvironment()')
expect(app).toContain('++todayEnvironmentLoadToken')
expect(app).toContain("activeView.value !== 'today'")
expect(app).toContain('<TodayEnvironmentStrip :environment="todayEnvironment" :loading="todayEnvironmentLoading" :failed="todayEnvironmentError" />')
})
it('keeps environment failures local and retains old values during refresh', () => {
const loader = app.slice(app.indexOf('async function loadTodayEnvironment'), app.indexOf('async function loadOverdueTasks'))
expect(loader).toContain('todayEnvironmentLoading.value = true')
expect(loader).not.toContain('todayEnvironment.value = null')
expect(loader).not.toContain('fail(')
expect(loader).not.toContain('toast(')
expect(loader).toContain('todayEnvironmentError.value = true')
})
it('refreshes Today environment on Shanghai date rollover and page restoration', () => {
expect(app).toContain("import { shanghaiDateKey, useTaskDueClock, watchShanghaiDateRollover } from './lib/task-due-clock'")
expect(app).toContain('watchShanghaiDateRollover(taskDueNowMs, handleTodayEnvironmentResume)')
expect(app).toContain('todayEnvironmentDateKey')
expect(app).toContain("document.addEventListener('visibilitychange', handleTodayEnvironmentResume)")
expect(app).toContain("window.addEventListener('focus', handleTodayEnvironmentResume)")
expect(app).toContain("window.addEventListener('pageshow', handleTodayEnvironmentResume)")
expect(app).toContain("document.removeEventListener('visibilitychange', handleTodayEnvironmentResume)")
expect(app).toContain("window.removeEventListener('focus', handleTodayEnvironmentResume)")
expect(app).toContain("window.removeEventListener('pageshow', handleTodayEnvironmentResume)")
expect(app).toContain('if (document.visibilityState === \'hidden\' || activeView.value !== \'today\') return')
expect(app).toContain('if (todayEnvironmentDateKey.value !== shanghaiDateKey()) void loadTodayEnvironment()')
})
it('keeps mobile environment copy to two rows including refresh states', () => {
expect(css).toMatch(/@container\(max-width:559px\)\{\.today-environment\{[^}]*grid-template-rows:repeat\(2,minmax\(0,auto\)\)/)
expect(css).toMatch(/\.today-environment__status\{[^}]*grid-row:1/)
expect(css).not.toContain('.today-environment__refreshing')
})
it('places one full-width information row inside the existing cream board', () => {
expect(app.match(/class="today-board"/g)).toHaveLength(1)
expect(css).toMatch(/\.today-environment\{[^}]*grid-column:1\/-1/)
expect(css).toMatch(/@container\(min-width:560px\)\{\.today-environment\{[^}]*flex-wrap:nowrap/)
expect(css).toMatch(/@container\(max-width:559px\)\{\.today-environment\{[^}]*grid-template-columns:/)
expect(css).toMatch(/\.today-environment__item\{[^}]*min-width:0/)
expect(css).not.toMatch(/\.today-environment[^}]*overflow-x:auto/)
})
})
@@ -0,0 +1,98 @@
import { afterEach, describe, expect, it } from 'vitest'
import { readFileSync } from 'node:fs'
import { resolve } from 'node:path'
import { createApp, h, nextTick } from 'vue'
import TodayEnvironmentStrip, { type TodayEnvironment } from './TodayEnvironmentStrip.vue'
const cleanups: Array<() => void> = []
async function mountStrip(props: { environment: TodayEnvironment | null; loading?: boolean; failed?: boolean }) {
const host = document.createElement('div')
document.body.append(host)
const app = createApp(() => h(TodayEnvironmentStrip, props))
app.mount(host)
cleanups.push(() => { app.unmount(); host.remove() })
await nextTick()
return host
}
afterEach(() => cleanups.splice(0).forEach((cleanup) => cleanup()))
const environment: TodayEnvironment = {
calendar: { solar_date: '2026年9月16日', weekday: '星期三', lunar_date: '农历八月初六' },
weather: { status: 'fresh', location: '宁波海曙', text: '多云', temperature_c: 27, observed_at: '2026-09-16T09:00:00+08:00' },
gold: { status: 'stale', symbol: 'Au99.99', price_cny_per_gram: 782.35, market_date: '2026-09-15', source: '上海黄金交易所' },
}
describe('TodayEnvironmentStrip', () => {
it('renders date, fixed-location weather, and delayed SGE Au99.99 quote', async () => {
const host = await mountStrip({ environment })
expect(host.querySelector('.today-environment')?.getAttribute('aria-label')).toBe('今日环境信息')
expect(host.textContent).toContain('2026年9月16日')
expect(host.textContent).toContain('星期三')
expect(host.textContent).toContain('农历八月初六')
expect(host.textContent).toContain('宁波海曙')
expect(host.textContent).toContain('多云')
expect(host.textContent).toContain('27°C')
expect(host.textContent).toContain('Au99.99 ¥782.35/g')
expect(host.textContent).toContain('上金所延时')
expect(host.textContent).toContain('2026-09-15')
expect(host.querySelector('.today-environment__gold')?.classList.contains('is-stale')).toBe(true)
})
it('accepts the aggregation endpoint field names and derives source status', async () => {
const host = await mountStrip({ environment: {
date: { solar_date: '2026-09-16', weekday: '星期三', lunar: '农历八月初六', timezone: 'Asia/Shanghai' },
weather: { temperature_c: 28.4, weather_code: 2, observed_at: '2026-09-16T14:15:00+08:00', source: 'Open-Meteo', stale: false },
gold: { contract: 'Au99.99', latest_price: '935.990', market_date: '2026-09-16', delayed: true, source: '上海黄金交易所', stale: true },
} as TodayEnvironment })
expect(host.textContent).toContain('2026-09-16 星期三')
expect(host.textContent).toMatch(/宁波海曙 · 多云\s+28\.4°C/)
expect(host.textContent).toContain('Au99.99 ¥935.99/g')
expect(host.textContent).toContain('上金所延时 · 2026-09-16')
expect(host.querySelector('.today-environment__gold')?.classList.contains('is-stale')).toBe(true)
})
it('shows local loading, cached, and per-source unavailable states', async () => {
const loading = await mountStrip({ environment: null, loading: true })
expect(loading.querySelector('.today-environment')?.getAttribute('aria-busy')).toBe('true')
expect(loading.textContent).toContain('环境信息加载中')
const partial = await mountStrip({ environment: {
calendar: environment.calendar,
weather: { status: 'stale', location: '宁波海曙', text: '小雨', temperature_c: 24 },
gold: { status: 'unavailable', symbol: 'Au99.99' },
} })
expect(partial.textContent).toContain('缓存')
expect(partial.textContent).toContain('Au99.99 暂不可用')
expect(partial.textContent).not.toContain('环境信息加载中')
})
it('keeps existing values visible while a refresh is in progress', async () => {
const host = await mountStrip({ environment, loading: true })
expect(host.textContent).toContain('27°C')
expect(host.textContent).toContain('Open-Meteo · 09:00')
expect(host.textContent).toContain('¥782.35/g')
expect(host.querySelector('.today-environment')?.getAttribute('aria-busy')).toBe('true')
const status = host.querySelector('.today-environment__status')
expect(status?.getAttribute('role')).toBe('status')
expect(status?.getAttribute('aria-live')).toBe('polite')
expect(status?.textContent).toContain('更新中')
})
it('announces refresh failure through the same live status slot', async () => {
const host = await mountStrip({ environment, failed: true })
const status = host.querySelector('.today-environment__status')
expect(status?.getAttribute('role')).toBe('status')
expect(status?.getAttribute('aria-live')).toBe('polite')
expect(status?.textContent).toContain('更新失败')
expect(host.querySelectorAll('.today-environment__refreshing')).toHaveLength(0)
})
it('keeps refresh announcements out of the visual row layout', () => {
const css = readFileSync(resolve(process.cwd(), 'src/style.css'), 'utf8')
expect(css).toContain('.today-environment__status:not(.today-environment__state){position:absolute;width:1px;height:1px;')
expect(css).toContain('@container(max-width:559px){.today-environment{display:grid;grid-template-columns:minmax(0,1fr) minmax(0,1fr);')
expect(css).toContain('.today-environment__item{display:flex;gap:4px;overflow:hidden}')
})
})
@@ -0,0 +1,107 @@
<script setup lang="ts">
import { computed } from 'vue'
export type EnvironmentStatus = 'fresh' | 'stale' | 'unavailable'
type CalendarValue = { solar_date: string; weekday: string; lunar_date: string }
type ApiDateValue = { solar_date: string; weekday: string; lunar: string; timezone?: string }
type WeatherValue = {
status?: EnvironmentStatus
location?: string
text?: string | null
temperature_c?: number | null
weather_code?: number | null
observed_at?: string | null
source?: string | null
stale?: boolean
}
type GoldValue = {
status?: EnvironmentStatus
symbol?: string
contract?: string
price_cny_per_gram?: number | null
latest_price?: number | string | null
market_date?: string | null
source?: string | null
quoted_at?: string | null
delayed?: boolean
stale?: boolean
}
export type TodayEnvironment = {
calendar?: CalendarValue
date?: ApiDateValue
weather: WeatherValue | null
gold: GoldValue | null
errors?: Record<string, string>
}
const props = withDefaults(defineProps<{
environment: TodayEnvironment | null
loading?: boolean
failed?: boolean
}>(), { loading: false, failed: false })
const calendar = computed<CalendarValue | null>(() => {
if (props.environment?.calendar) return props.environment.calendar
const value = props.environment?.date
return value ? { solar_date: value.solar_date, weekday: value.weekday, lunar_date: value.lunar } : null
})
const weatherStatus = computed<EnvironmentStatus>(() => {
if (!props.environment?.weather) return 'unavailable'
return props.environment.weather.status ?? (props.environment.weather.stale ? 'stale' : 'fresh')
})
const goldStatus = computed<EnvironmentStatus>(() => {
if (!props.environment?.gold) return 'unavailable'
return props.environment.gold.status ?? (props.environment.gold.stale ? 'stale' : 'fresh')
})
const weatherText = computed(() => {
const explicit = props.environment?.weather?.text
if (explicit) return explicit
const code = props.environment?.weather?.weather_code
if (code == null) return '天气暂缺'
if (code === 0) return '晴'
if ([1, 2, 3].includes(code)) return '多云'
if ([45, 48].includes(code)) return '雾'
if (code >= 51 && code <= 67 || code >= 80 && code <= 82) return '雨'
if (code >= 71 && code <= 77 || code >= 85) return '雪'
if (code >= 95) return '雷雨'
return '天气暂缺'
})
const goldPrice = computed(() => {
const value = props.environment?.gold?.price_cny_per_gram ?? props.environment?.gold?.latest_price
const numeric = typeof value === 'string' ? Number(value) : value
return typeof numeric === 'number' && Number.isFinite(numeric) ? numeric.toFixed(2) : null
})
const weatherObservation = computed(() => {
const value = props.environment?.weather
if (!value || weatherStatus.value === 'unavailable') return ''
const source = value.source || 'Open-Meteo'
if (!value.observed_at) return source
const observed = new Date(value.observed_at)
if (Number.isNaN(observed.getTime())) return source
return `${source} · ${new Intl.DateTimeFormat('zh-CN', { timeZone: 'Asia/Shanghai', hour: '2-digit', minute: '2-digit', hour12: false }).format(observed)}`
})
</script>
<template>
<div class="today-environment" aria-label="今日环境信息" :aria-busy="loading">
<span v-if="!environment && loading" class="today-environment__state today-environment__status" role="status" aria-live="polite">环境信息加载中</span>
<span v-else-if="!environment" class="today-environment__state today-environment__status" role="status" aria-live="polite">环境信息暂不可用</span>
<template v-else>
<span v-if="calendar" class="today-environment__item today-environment__calendar">
<strong>{{ calendar.solar_date }} {{ calendar.weekday }}</strong>
<small>{{ calendar.lunar_date }}</small>
</span>
<span class="today-environment__item today-environment__weather" :class="`is-${weatherStatus}`">
<strong v-if="weatherStatus !== 'unavailable'">{{ environment.weather?.location || '宁波海曙' }} · {{ weatherText }}<template v-if="environment.weather?.temperature_c != null">&nbsp;{{ environment.weather.temperature_c }}°C</template></strong>
<strong v-else>宁波海曙天气暂不可用</strong>
<small v-if="weatherStatus !== 'unavailable'">{{ weatherObservation }}<template v-if="weatherStatus === 'stale'"> · 缓存</template></small>
</span>
<span class="today-environment__item today-environment__gold" :class="`is-${goldStatus}`">
<strong v-if="goldStatus !== 'unavailable' && goldPrice">{{ environment.gold?.symbol || environment.gold?.contract || 'Au99.99' }} ¥{{ goldPrice }}/g</strong>
<strong v-else>Au99.99 暂不可用</strong>
<small><template v-if="goldStatus !== 'unavailable'">上金所延时<template v-if="environment.gold?.market_date"> · {{ environment.gold.market_date }}</template></template><template v-if="goldStatus === 'stale'"> · 缓存</template></small>
</span>
<span class="today-environment__status" role="status" aria-live="polite"><template v-if="loading">更新中…</template><template v-else-if="failed">更新失败显示上次信息</template></span>
</template>
</div>
</template>
+16 -2
View File
@@ -1,6 +1,6 @@
import { afterEach, describe, expect, it, vi } from 'vitest'
import { createApp, h } from 'vue'
import { useTaskDueClock } from './task-due-clock'
import { useTaskDueClock, watchShanghaiDateRollover } from './task-due-clock'
const cleanups: Array<() => void> = []
@@ -10,12 +10,13 @@ afterEach(() => {
vi.restoreAllMocks()
})
function mountClock() {
function mountClock(onShanghaiDateRollover?: () => void) {
let clock: ReturnType<typeof useTaskDueClock> | undefined
const host = document.createElement('div')
const app = createApp({
setup() {
clock = useTaskDueClock()
if (onShanghaiDateRollover) watchShanghaiDateRollover(clock, onShanghaiDateRollover)
return () => h('span', String(clock!.value))
},
})
@@ -65,6 +66,19 @@ describe('useTaskDueClock', () => {
expect(vi.getTimerCount()).toBe(1)
})
it('refreshes once when the shared visible clock crosses Shanghai midnight', () => {
vi.useFakeTimers()
vi.setSystemTime(new Date('2026-09-16T15:59:30.000Z'))
const refresh = vi.fn()
mountClock(refresh)
vi.advanceTimersByTime(30_000)
expect(refresh).toHaveBeenCalledTimes(1)
vi.advanceTimersByTime(60_000)
expect(refresh).toHaveBeenCalledTimes(1)
})
it('removes visibility, focus and pageshow listeners and clears the timer on unmount', () => {
vi.useFakeTimers()
const removeDocument = vi.spyOn(document, 'removeEventListener')
+20 -1
View File
@@ -1,4 +1,23 @@
import { onMounted, onUnmounted, ref, type Ref } from 'vue'
import { onMounted, onUnmounted, ref, watch, type Ref } from 'vue'
export function shanghaiDateKey(nowMs = Date.now()) {
return new Intl.DateTimeFormat('en-CA', {
timeZone: 'Asia/Shanghai',
year: 'numeric',
month: '2-digit',
day: '2-digit',
}).format(new Date(nowMs))
}
export function watchShanghaiDateRollover(clock: Ref<number>, refresh: () => void) {
let dateKey = shanghaiDateKey(clock.value)
return watch(clock, (nowMs) => {
const nextKey = shanghaiDateKey(nowMs)
if (nextKey === dateKey) return
dateKey = nextKey
refresh()
}, { flush: 'sync' })
}
function nextRefreshDelay(nowMs: number) {
const now = new Date(nowMs)
+3
View File
@@ -48,6 +48,9 @@ main{container-type:inline-size;min-width:0;padding:27px 34px 50px;overflow:auto
.task-row.just-completed,.habit-row.just-completed{animation:completion-row-settle .34s cubic-bezier(.2,.85,.3,1)}.task-row.just-completed .task-check-mark,.habit-row.just-completed .task-check-mark{animation:completion-check-pop .38s cubic-bezier(.2,1.4,.35,1)}.task-row.completion-exiting,.habit-row.completion-exiting{pointer-events:none;overflow:hidden;animation:completion-slide-out .32s cubic-bezier(.4,0,.8,.2) forwards}@keyframes completion-slide-out{0%{opacity:1;transform:translate(var(--swipe-x),var(--reorder-y,0px));max-height:180px}68%{opacity:0;transform:translate(calc(100% + 24px),var(--reorder-y,0px));max-height:180px}100%{opacity:0;transform:translate(calc(100% + 24px),var(--reorder-y,0px));max-height:0;min-height:0;padding-top:0;padding-bottom:0;border-width:0}}@keyframes completion-row-settle{0%{background:rgba(113,133,107,.16);transform:translate(var(--swipe-x),var(--reorder-y,0px)) scale(1)}45%{background:rgba(113,133,107,.1);transform:translate(var(--swipe-x),var(--reorder-y,0px)) scale(.992)}100%{transform:translate(var(--swipe-x),var(--reorder-y,0px)) scale(1)}}@keyframes completion-check-pop{0%{transform:scale(.72);box-shadow:0 0 0 0 rgba(113,133,107,.28)}58%{transform:scale(1.18);box-shadow:0 0 0 7px rgba(113,133,107,0)}100%{transform:scale(1);box-shadow:none}}@media(prefers-reduced-motion:reduce){.task-row.just-completed,.habit-row.just-completed,.task-row.just-completed .task-check-mark,.habit-row.just-completed .task-check-mark{animation:none}.task-row.completion-exiting,.habit-row.completion-exiting{animation:none}}
/* Today environment information strip. */
.today-environment{grid-column:1/-1;min-width:0;display:flex;align-items:center;gap:12px;padding:0 0 12px;border-bottom:1px solid var(--border-cream);color:var(--text-secondary)}.today-environment__item{min-width:0;display:flex;align-items:baseline;gap:6px;white-space:nowrap;overflow:hidden}.today-environment__item strong,.today-environment__item small{overflow:hidden;text-overflow:ellipsis}.today-environment__item strong{color:var(--text-primary);font-size:13px}.today-environment__item small,.today-environment__status,.today-environment__state{color:var(--muted);font-size:11px}.today-environment__status:not(.today-environment__state){position:absolute;width:1px;height:1px;padding:0;margin:-1px;overflow:hidden;clip:rect(0,0,0,0);white-space:nowrap;border:0}.today-environment__item+.today-environment__item{padding-left:12px;border-left:1px solid var(--border-cream)}.today-environment__item.is-stale small{color:#9a704c}.today-environment__status{margin-left:auto;white-space:nowrap}.today-environment__status:empty{display:none}.today-environment__state{min-height:22px;display:flex;align-items:center}@container(min-width:560px){.today-environment{flex-wrap:nowrap}.today-environment__calendar{flex:1}.today-environment__weather,.today-environment__gold{flex:0 1 auto}}@container(max-width:559px){.today-environment{display:grid;grid-template-columns:minmax(0,1fr) minmax(0,1fr);grid-template-rows:repeat(2,minmax(0,auto));gap:7px 10px}.today-environment__calendar{grid-column:1/-1;grid-row:1}.today-environment__item+.today-environment__item{padding-left:0;border-left:0}.today-environment__weather{grid-column:1;grid-row:2}.today-environment__gold{grid-column:2;grid-row:2}.today-environment__status{grid-column:1/-1;grid-row:1;justify-self:end;max-width:42%;margin-left:0;overflow:hidden;text-overflow:ellipsis}.today-environment__calendar{padding-right:42%}.today-environment__item{display:flex;gap:4px;overflow:hidden}.today-environment__item strong{font-size:12px}.today-environment__item small{font-size:10px}}
/* Solid cream material system: opaque surfaces, warm edges, quiet depth. */
:root{--surface-canvas:#f3ecdf;--surface-base:#fdfaf3;--surface-raised:#fffdf8;--text-primary:#302a24;--text-secondary:#655b50;--border-cream:#e4d5c0;--highlight-inner:inset 0 1px 0 #fff;--shadow-soft:0 4px 14px rgba(88,67,42,.08);--shadow-raised:0 12px 30px rgba(88,67,42,.12);--focus-ring:rgba(180,66,30,.34);--success:#687e61;--scrim:rgba(45,38,31,.38);--radius-control:11px;--radius-card:14px;--radius-list:15px;--radius-panel:20px;--paper:var(--surface-raised);--sidebar:var(--surface-canvas);--line:var(--border-cream);--muted:var(--text-secondary);--shadow:var(--shadow-raised);--sheet-radius:20px;--sheet-scrim:var(--scrim)}
body{color:var(--text-primary);background:var(--surface-canvas);font-variant-numeric:tabular-nums}