update at 2026-03-15 15:58:14

This commit is contained in:
douboer@gmail.com
2026-03-15 15:58:14 +08:00
parent b38d932a05
commit 4b280073d4
24 changed files with 1509 additions and 596 deletions

View File

@@ -5,7 +5,9 @@
"type": "module",
"scripts": {
"dev": "vite",
"build": "vue-tsc --noEmit && vite build",
"build": "vue-tsc --noEmit && vite build && node scripts/generate-dashboard-manifest.mjs",
"export:background": "sh scripts/export-kindle-background.sh",
"manifest": "node scripts/generate-dashboard-manifest.mjs",
"typecheck": "vue-tsc --noEmit",
"preview": "vite preview"
},

View File

@@ -0,0 +1,23 @@
#!/usr/bin/env sh
set -eu
ROOT_DIR="$(CDPATH= cd -- "$(dirname "$0")/../.." && pwd)"
CALENDAR_DIR="$ROOT_DIR/calendar"
DIST_DIR="$CALENDAR_DIR/dist"
PORT=${PORT:-4173}
URL=${1:-"http://127.0.0.1:$PORT/?mode=background"}
OUT_PNG=${2:-"$DIST_DIR/kindlebg.png"}
OUT_REGION=${3:-"$DIST_DIR/clock-region.json"}
cd "$CALENDAR_DIR"
npm run build >/dev/null
python3 -m http.server "$PORT" -d "$DIST_DIR" >/tmp/kindle-calendar-http.log 2>&1 &
SERVER_PID=$!
trap 'kill "$SERVER_PID" 2>/dev/null || true' EXIT INT TERM
sleep 1
/usr/bin/swift "$CALENDAR_DIR/scripts/export-kindle-background.swift" "$URL" "$OUT_PNG" "$OUT_REGION"
node "$CALENDAR_DIR/scripts/generate-dashboard-manifest.mjs" >/dev/null
cat "$OUT_REGION"

View File

@@ -0,0 +1,186 @@
#!/usr/bin/env swift
import AppKit
import Foundation
import WebKit
enum ExportError: Error, CustomStringConvertible {
case invalidArguments
case snapshotFailed
case pngEncodingFailed(String)
case clockRegionMissing
var description: String {
switch self {
case .invalidArguments:
return "用法export-kindle-background.swift <url> <png-output> <region-output>"
case .snapshotFailed:
return "网页截图失败"
case let .pngEncodingFailed(path):
return "PNG 编码失败:\(path)"
case .clockRegionMissing:
return "页面里没有找到 data-clock-region 标记"
}
}
}
final class SnapshotExporter: NSObject, WKNavigationDelegate {
private let url: URL
private let pngOutputURL: URL
private let regionOutputURL: URL
private let completion: (Result<Void, Error>) -> Void
private let targetSize = CGSize(width: 1024, height: 600)
private lazy var window: NSWindow = {
let window = NSWindow(
contentRect: CGRect(origin: .zero, size: targetSize),
styleMask: [.borderless],
backing: .buffered,
defer: false
)
window.isReleasedWhenClosed = false
window.backgroundColor = .white
return window
}()
private lazy var webView: WKWebView = {
let config = WKWebViewConfiguration()
let view = WKWebView(frame: CGRect(origin: .zero, size: targetSize), configuration: config)
view.navigationDelegate = self
return view
}()
init(url: URL, pngOutputURL: URL, regionOutputURL: URL, completion: @escaping (Result<Void, Error>) -> Void) {
self.url = url
self.pngOutputURL = pngOutputURL
self.regionOutputURL = regionOutputURL
self.completion = completion
super.init()
}
func start() {
window.contentView = webView
window.orderBack(nil)
webView.load(URLRequest(url: url))
}
func webView(_ webView: WKWebView, didFinish _: WKNavigation!) {
//
DispatchQueue.main.asyncAfter(deadline: .now() + 4) {
self.capture()
}
}
private func capture() {
let regionScript = """
(() => {
const node = document.querySelector('[data-clock-region="true"]');
if (!node) return null;
const rect = node.getBoundingClientRect();
return {
x: Math.round(rect.left),
y: Math.round(rect.top),
width: Math.round(rect.width),
height: Math.round(rect.height)
};
})();
"""
webView.evaluateJavaScript(regionScript) { value, error in
if let error {
self.finish(.failure(error))
return
}
guard let region = value as? [String: NSNumber] else {
self.finish(.failure(ExportError.clockRegionMissing))
return
}
let snapshotConfig = WKSnapshotConfiguration()
snapshotConfig.rect = CGRect(origin: .zero, size: self.targetSize)
snapshotConfig.snapshotWidth = NSNumber(value: Float(self.targetSize.width))
self.webView.takeSnapshot(with: snapshotConfig) { image, snapshotError in
if let snapshotError {
self.finish(.failure(snapshotError))
return
}
guard let image else {
self.finish(.failure(ExportError.snapshotFailed))
return
}
do {
try self.savePNG(image: image, to: self.pngOutputURL)
try self.saveRegion(region: region)
self.finish(.success(()))
} catch {
self.finish(.failure(error))
}
}
}
}
private func savePNG(image: NSImage, to url: URL) throws {
let normalizedImage = NSImage(size: NSSize(width: targetSize.width, height: targetSize.height))
normalizedImage.lockFocus()
image.draw(in: NSRect(origin: .zero, size: targetSize))
normalizedImage.unlockFocus()
guard
let tiffRepresentation = normalizedImage.tiffRepresentation,
let bitmap = NSBitmapImageRep(data: tiffRepresentation),
let pngData = bitmap.representation(using: .png, properties: [:])
else {
throw ExportError.pngEncodingFailed(url.path)
}
try FileManager.default.createDirectory(at: url.deletingLastPathComponent(), withIntermediateDirectories: true)
try pngData.write(to: url)
}
private func saveRegion(region: [String: NSNumber]) throws {
let jsonObject: [String: Int] = [
"x": region["x"]?.intValue ?? 0,
"y": region["y"]?.intValue ?? 0,
"width": region["width"]?.intValue ?? 0,
"height": region["height"]?.intValue ?? 0,
]
let data = try JSONSerialization.data(withJSONObject: jsonObject, options: [.prettyPrinted, .sortedKeys])
try FileManager.default.createDirectory(at: regionOutputURL.deletingLastPathComponent(), withIntermediateDirectories: true)
try data.write(to: regionOutputURL)
}
private func finish(_ result: Result<Void, Error>) {
completion(result)
NSApplication.shared.terminate(nil)
}
}
guard CommandLine.arguments.count >= 4 else {
fputs("\(ExportError.invalidArguments)\n", stderr)
exit(1)
}
let url = URL(string: CommandLine.arguments[1])!
let pngOutputURL = URL(fileURLWithPath: CommandLine.arguments[2])
let regionOutputURL = URL(fileURLWithPath: CommandLine.arguments[3])
let app = NSApplication.shared
app.setActivationPolicy(.prohibited)
let exporter = SnapshotExporter(url: url, pngOutputURL: pngOutputURL, regionOutputURL: regionOutputURL) { result in
switch result {
case .success:
print("Exported \(pngOutputURL.path)")
print("Region saved to \(regionOutputURL.path)")
case let .failure(error):
fputs("\(error)\n", stderr)
exit(1)
}
}
exporter.start()
app.run()

View File

@@ -0,0 +1,50 @@
import fs from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
const currentDir = path.dirname(fileURLToPath(import.meta.url));
const distDir = path.resolve(currentDir, '../dist');
const manifestPath = path.join(distDir, 'dashboard-manifest.json');
const clockRegionPath = path.join(distDir, 'clock-region.json');
const defaultClockRegion = {
x: 313,
y: 0,
width: 220,
height: 220,
};
const clockRegion = fs.existsSync(clockRegionPath)
? {
...defaultClockRegion,
...JSON.parse(fs.readFileSync(clockRegionPath, 'utf8')),
}
: defaultClockRegion;
const manifest = {
background: {
path: 'kindlebg.png',
url: 'https://shell.biboer.cn:20001/kindlebg.png',
updatedAt: new Date().toISOString(),
refreshIntervalMinutes: 120,
},
clockRegion,
clockFace: {
path: 'assets/clock-face.png',
managedOnKindle: true,
designWidth: 220,
designHeight: 220,
},
clockHands: {
hourPattern: 'assets/hour-hand/%03d.png',
minutePattern: 'assets/minute-hand/%02d.png',
refreshIntervalMinutes: 1,
networkRequired: false,
anchorMode: 'baked-into-patch',
scaleWithClockFace: false,
},
};
fs.mkdirSync(distDir, { recursive: true });
fs.writeFileSync(manifestPath, `${JSON.stringify(manifest, null, 2)}\n`, 'utf8');
console.log(`Wrote ${manifestPath}`);

View File

@@ -1,14 +1,17 @@
<script setup lang="ts">
import { computed, onBeforeUnmount, onMounted, ref } from 'vue';
import AnalogClock from '@/components/AnalogClock.vue';
import CalendarCard from '@/components/CalendarCard.vue';
import QuoteCard from '@/components/QuoteCard.vue';
import WeatherCard from '@/components/WeatherCard.vue';
import { buildCalendarModel } from '@/lib/calendar';
import { resolveDashboardMode } from '@/lib/dashboard-mode';
import { getQuoteForDate } from '@/lib/quotes';
import { fetchWeather, resolveLocation, type LocationCoordinates, type WeatherSnapshot } from '@/lib/weather';
const now = ref(new Date());
const mode = ref(resolveDashboardMode(window.location.search));
const location = ref<LocationCoordinates>({
latitude: 31.2304,
longitude: 121.4737,
@@ -22,6 +25,7 @@ let weatherTimer = 0;
const calendarModel = computed(() => buildCalendarModel(now.value));
const quoteEntry = computed(() => getQuoteForDate(now.value));
const isClockFaceMode = computed(() => mode.value === 'clock-face');
async function refreshWeather() {
weatherStatus.value = 'loading';
@@ -35,31 +39,46 @@ async function refreshWeather() {
}
}
function syncMode() {
mode.value = resolveDashboardMode(window.location.search);
}
onMounted(async () => {
location.value = await resolveLocation();
await refreshWeather();
window.addEventListener('popstate', syncMode);
if (!isClockFaceMode.value) {
location.value = await resolveLocation();
await refreshWeather();
}
clockTimer = window.setInterval(() => {
now.value = new Date();
}, 30 * 1000);
// 天气不需要秒级刷新30 分钟一次足够。
weatherTimer = window.setInterval(() => {
void refreshWeather();
}, 30 * 60 * 1000);
if (!isClockFaceMode.value) {
// 天气数据属于低频区块,只需要半小时刷新一次。
weatherTimer = window.setInterval(() => {
void refreshWeather();
}, 30 * 60 * 1000);
}
});
onBeforeUnmount(() => {
window.clearInterval(clockTimer);
window.clearInterval(weatherTimer);
window.removeEventListener('popstate', syncMode);
});
</script>
<template>
<main class="page-shell">
<div class="dashboard-frame">
<main :class="['page-shell', `page-shell--${mode}`]">
<section v-if="isClockFaceMode" class="clock-face-stage">
<AnalogClock :date="now" mode="clock-face" :size="220" />
</section>
<div v-else class="dashboard-frame">
<div class="dashboard-grid">
<CalendarCard :model="calendarModel" />
<CalendarCard :model="calendarModel" :date="now" :mode="mode" />
<WeatherCard
:weather="weather"
:status="weatherStatus"

View File

@@ -0,0 +1,126 @@
<script setup lang="ts">
import { computed } from 'vue';
import type { DashboardMode } from '@/lib/dashboard-mode';
import {
buildClockState,
CLOCK_FACE_ASSET,
CLOCK_FACE_SOURCE_SIZE,
CLOCK_RENDER_SIZE,
HOUR_HAND_SPEC,
MINUTE_HAND_SPEC,
type ClockHandSpec,
} from '@/lib/clock';
const props = withDefaults(
defineProps<{
date: Date;
mode: DashboardMode;
size?: number;
}>(),
{
size: CLOCK_RENDER_SIZE,
},
);
const clockState = computed(() => buildClockState(props.date));
const scale = computed(() => props.size / CLOCK_FACE_SOURCE_SIZE);
function buildHandStyle(spec: ClockHandSpec, angle: number) {
const scaledWidth = spec.sourceWidth * scale.value;
const scaledHeight = spec.sourceHeight * scale.value;
const left = props.size / 2 - spec.pivotX * scale.value;
const top = props.size / 2 - spec.pivotY * scale.value;
return {
width: `${scaledWidth}px`,
height: `${scaledHeight}px`,
left: `${left}px`,
top: `${top}px`,
transformOrigin: `${spec.pivotX * scale.value}px ${spec.pivotY * scale.value}px`,
transform: `rotate(${angle}deg)`,
};
}
const hourHandStyle = computed(() => buildHandStyle(HOUR_HAND_SPEC, clockState.value.hourAngle));
const minuteHandStyle = computed(() => buildHandStyle(MINUTE_HAND_SPEC, clockState.value.minuteAngle));
const stageStyle = computed(() => ({
width: `${props.size}px`,
height: `${props.size}px`,
}));
</script>
<template>
<div
class="analog-clock"
:class="`analog-clock--${mode}`"
:style="stageStyle"
data-clock-region="true"
>
<div v-if="mode === 'background'" class="analog-clock__placeholder" />
<template v-else>
<img class="analog-clock__face" :src="CLOCK_FACE_ASSET" alt="时钟表盘" />
<img
v-if="mode === 'full'"
class="analog-clock__hand analog-clock__hand--hour"
:src="HOUR_HAND_SPEC.src"
alt=""
aria-hidden="true"
:style="hourHandStyle"
/>
<img
v-if="mode === 'full'"
class="analog-clock__hand analog-clock__hand--minute"
:src="MINUTE_HAND_SPEC.src"
alt=""
aria-hidden="true"
:style="minuteHandStyle"
/>
<span v-if="mode === 'full'" class="analog-clock__sr">{{ clockState.digitalLabel }}</span>
</template>
</div>
</template>
<style scoped>
.analog-clock {
position: relative;
flex: 0 0 auto;
}
.analog-clock__face,
.analog-clock__placeholder {
width: 100%;
height: 100%;
}
.analog-clock__face {
object-fit: contain;
}
.analog-clock__placeholder {
border-radius: 50%;
background:
radial-gradient(circle at 50% 50%, rgba(255, 255, 255, 0.94), rgba(255, 250, 246, 0.78) 68%, rgba(239, 226, 221, 0.52));
box-shadow:
inset 0 0 0 1px rgba(214, 196, 187, 0.35),
inset 0 0 24px rgba(255, 255, 255, 0.45);
}
.analog-clock__hand {
position: absolute;
max-width: none;
}
.analog-clock__sr {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
white-space: nowrap;
border: 0;
}
</style>

View File

@@ -1,10 +1,14 @@
<script setup lang="ts">
import { computed } from 'vue';
import AnalogClock from '@/components/AnalogClock.vue';
import type { CalendarModel } from '@/lib/calendar';
import type { DashboardMode } from '@/lib/dashboard-mode';
const props = defineProps<{
model: CalendarModel;
date: Date;
mode: DashboardMode;
}>();
const weeks = computed(() => {
@@ -16,55 +20,285 @@ const weeks = computed(() => {
return chunks;
});
function subLabelTone(cell: CalendarModel['cells'][number]) {
return cell.badges[0]?.tone ?? 'lunar';
}
</script>
<template>
<section class="card calendar-card">
<div class="calendar-card__header">
<div class="calendar-card__day">{{ model.largeDay }}</div>
<div class="calendar-card__meta">
<p class="calendar-card__date-line">
<span>{{ model.gregorianLabel }}</span>
<span class="calendar-card__weekday">{{ model.weekdayLabel }}</span>
</p>
<p class="calendar-card__lunar">
<span>{{ model.lunarYearLabel }}</span>
<span>{{ model.lunarDayLabel }}</span>
<span v-if="model.summaryBadges.length" class="calendar-card__badges calendar-card__badges--inline">
<span
v-for="badge in model.summaryBadges"
:key="badge.label"
:class="['calendar-card__badge', `calendar-card__badge--${badge.tone}`]"
>
{{ badge.label }}
</span>
</span>
</p>
<div class="calendar-card__hero">
<div class="calendar-card__headline">
<div class="calendar-card__day">{{ model.largeDay }}</div>
<div class="calendar-card__headline-copy">
<p class="calendar-card__lunar-day">{{ model.lunarDayLabel }}</p>
<p class="calendar-card__weekday">{{ model.weekdayLabel }}</p>
</div>
</div>
<AnalogClock :date="date" :mode="mode" :size="220" />
</div>
<div class="calendar-card__grid">
<div v-for="label in model.weekLabels" :key="label" class="calendar-card__week-label">
{{ label }}
<div class="calendar-card__panel">
<div class="calendar-card__panel-header">
<div>
<p class="calendar-card__panel-title">{{ model.gregorianLabel }}</p>
<p class="calendar-card__panel-subtitle">{{ model.lunarYearLabel }}</p>
</div>
<div v-if="model.summaryBadges.length" class="calendar-card__badges">
<span
v-for="badge in model.summaryBadges"
:key="badge.label"
:class="['calendar-card__badge', `calendar-card__badge--${badge.tone}`]"
>
{{ badge.label }}
</span>
</div>
</div>
<template v-for="week in weeks" :key="week[0].date.toISOString()">
<div
v-for="cell in week"
:key="cell.date.toISOString()"
:class="[
'calendar-card__cell',
{
'calendar-card__cell--muted': !cell.currentMonth,
'calendar-card__cell--today': cell.isToday,
'calendar-card__cell--holiday': cell.badges.some((badge) => badge.tone === 'holiday'),
},
]"
>
<span class="calendar-card__solar">{{ cell.day }}</span>
<span class="calendar-card__lunar-day">{{ cell.subLabel }}</span>
<div class="calendar-card__grid">
<div v-for="label in model.weekLabels" :key="label" class="calendar-card__week-label">
{{ label }}
</div>
</template>
<template v-for="week in weeks" :key="week[0].date.toISOString()">
<div
v-for="cell in week"
:key="cell.date.toISOString()"
:class="[
'calendar-card__cell',
{
'calendar-card__cell--muted': !cell.currentMonth,
'calendar-card__cell--today': cell.isToday,
'calendar-card__cell--holiday': cell.badges.some((badge) => badge.tone === 'holiday'),
},
]"
>
<div class="calendar-card__cell-copy">
<span class="calendar-card__solar">{{ cell.day }}</span>
<span
:class="[
'calendar-card__sub',
`calendar-card__sub--${subLabelTone(cell)}`,
]"
>
{{ cell.subLabel }}
</span>
</div>
</div>
</template>
</div>
</div>
</section>
</template>
<style scoped>
.calendar-card {
display: grid;
grid-template-rows: auto 1fr;
height: 100%;
gap: 0.95rem;
padding: 1.28rem 1.32rem 1.12rem;
}
.calendar-card__hero {
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.9rem;
}
.calendar-card__headline {
display: grid;
align-content: center;
flex: 1 1 auto;
min-width: 0;
gap: 0.18rem;
}
.calendar-card__headline-copy {
display: flex;
flex: 0 0 auto;
flex-wrap: wrap;
gap: 0.45rem;
}
.calendar-card__day {
font-family:
'Iowan Old Style',
'Baskerville',
serif;
font-size: 5.7rem;
line-height: 0.88;
letter-spacing: -0.08em;
color: #111111;
}
.calendar-card__lunar-day,
.calendar-card__weekday {
margin: 0;
font-size: 1.48rem;
line-height: 1.02;
color: #232323;
white-space: nowrap;
}
.calendar-card__weekday {
font-size: 1.54rem;
}
.calendar-card__panel {
display: grid;
grid-template-rows: auto 1fr;
gap: 0.45rem;
min-height: 0;
padding: 0.74rem 0.9rem 0.84rem;
border-radius: 1.1rem;
background: linear-gradient(180deg, rgba(250, 237, 238, 0.58), rgba(247, 240, 239, 0.86));
}
.calendar-card__panel-header {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 1rem;
}
.calendar-card__panel-title,
.calendar-card__panel-subtitle {
margin: 0;
}
.calendar-card__panel-title {
font-size: 0.9rem;
color: #4c4c4c;
}
.calendar-card__panel-subtitle {
margin-top: 0.2rem;
font-size: 0.76rem;
color: #7a6a61;
}
.calendar-card__badges {
display: flex;
gap: 0.4rem;
flex-wrap: wrap;
justify-content: flex-end;
}
.calendar-card__badge {
padding: 0.12rem 0.42rem;
border-radius: 999px;
font-size: 0.66rem;
line-height: 1.2;
border: 1px solid currentColor;
background: rgba(255, 255, 255, 0.66);
}
.calendar-card__badge--holiday {
color: #8b4a20;
}
.calendar-card__badge--workday {
color: #7c5d2f;
}
.calendar-card__badge--festival {
color: #67503d;
}
.calendar-card__badge--term {
color: #4a6b6f;
}
.calendar-card__grid {
display: grid;
grid-template-columns: repeat(7, minmax(0, 1fr));
grid-auto-rows: minmax(0, 1fr);
column-gap: 0.16rem;
row-gap: 0.04rem;
min-height: 0;
align-content: start;
}
.calendar-card__week-label {
display: grid;
place-items: center;
padding-bottom: 0.08rem;
font-size: 0.7rem;
color: #7b7b7b;
}
.calendar-card__cell {
display: grid;
place-items: center;
min-height: 0;
padding: 0.08rem 0 0.1rem;
border-radius: 0.78rem;
color: #3f454e;
}
.calendar-card__cell-copy {
display: grid;
justify-items: center;
gap: 0.04rem;
min-width: 0;
}
.calendar-card__cell--muted {
color: #b7b0b3;
}
.calendar-card__cell--today {
background: #171717;
color: #ffffff;
}
.calendar-card__cell--holiday .calendar-card__solar {
color: #b76114;
}
.calendar-card__solar {
font-size: 0.92rem;
line-height: 1.05;
}
.calendar-card__sub {
max-width: 100%;
overflow: hidden;
font-size: 0.42rem;
line-height: 1;
color: #8b8b8b;
text-overflow: ellipsis;
white-space: nowrap;
}
.calendar-card__sub--holiday {
color: #b76114;
}
.calendar-card__sub--workday {
color: #7c5d2f;
}
.calendar-card__sub--festival {
color: #67503d;
}
.calendar-card__sub--term {
color: #4a6b6f;
}
.calendar-card__sub--lunar {
color: #8b8b8b;
}
.calendar-card__cell--muted .calendar-card__sub {
color: #c3bbbe;
}
.calendar-card__cell--today .calendar-card__sub {
color: rgba(255, 255, 255, 0.84);
}
</style>

View File

@@ -1,6 +1,8 @@
<script setup lang="ts">
import { computed } from 'vue';
import { QUOTE_ICON_ASSET } from '@/lib/icon-assets';
const props = defineProps<{
quote: string;
}>();
@@ -8,20 +10,62 @@ const props = defineProps<{
const quoteFontSize = computed(() => {
const length = props.quote.length;
if (length > 140) {
return '1.75rem';
if (length > 120) {
return '1.05rem';
}
if (length > 100) {
return '1.95rem';
if (length > 80) {
return '1.22rem';
}
return '2.2rem';
if (length > 48) {
return '1.4rem';
}
return '1.6rem';
});
</script>
<template>
<section class="card quote-card">
<div class="quote-card__header">
<img class="quote-card__icon" :src="QUOTE_ICON_ASSET" alt="" aria-hidden="true" />
<span class="quote-card__title">每日鸡汤</span>
</div>
<p class="quote-card__content" :style="{ fontSize: quoteFontSize }">{{ quote }}</p>
</section>
</template>
<style scoped>
.quote-card {
grid-column: 1 / -1;
display: grid;
gap: 0.34rem;
padding: 0.72rem 1.02rem;
background: linear-gradient(180deg, rgba(255, 248, 230, 0.96), rgba(255, 252, 243, 0.94));
overflow: hidden;
}
.quote-card__header {
display: inline-flex;
align-items: center;
gap: 0.45rem;
color: #c75d00;
}
.quote-card__icon {
width: 0.9rem;
height: 0.9rem;
}
.quote-card__title {
font-size: 0.8rem;
font-weight: 600;
}
.quote-card__content {
margin: 0;
line-height: 1.34;
color: #292929;
}
</style>

View File

@@ -28,7 +28,7 @@ function weatherKind(code: number) {
}
if ([51, 53, 55, 56, 57, 61, 63, 65, 66, 67, 80, 81, 82].includes(code)) {
return 'rain';
return [65, 67, 81, 82].includes(code) ? 'heavy-rain' : 'rain';
}
if ([71, 73, 75, 77, 85, 86].includes(code)) {
@@ -42,7 +42,7 @@ function weatherKind(code: number) {
return 'cloudy';
}
const forecast = computed(() => props.weather?.forecast.slice(0, 5) ?? []);
const forecast = computed(() => props.weather?.forecast.slice(0, 4) ?? []);
const metrics = computed(() => {
if (!props.weather) {
@@ -53,14 +53,12 @@ const metrics = computed(() => {
{
label: '日出',
value: props.weather.sunrise,
accent: 'metric--sunrise',
icon: 'sunrise',
accent: 'metric-pill--sunrise',
},
{
label: '日落',
value: props.weather.sunset,
accent: 'metric--sunset',
icon: 'sunset',
accent: 'metric-pill--sunset',
},
{
label: '空气质量',
@@ -68,14 +66,12 @@ const metrics = computed(() => {
props.weather.aqi === null
? '暂无'
: `${props.weather.aqi}${props.weather.aqiLabel}`,
accent: 'metric--air',
icon: 'air',
accent: 'metric-pill--air',
},
{
label: '能见度',
value: `${props.weather.visibilityKm} km`,
accent: 'metric--visibility',
icon: 'visibility',
accent: 'metric-pill--visibility',
},
] as const;
});
@@ -119,11 +115,11 @@ function forecastKind(day: ForecastDay) {
<div class="weather-card__facts">
<div class="weather-card__fact">
<WeatherGlyph kind="humidity" />
<span class="weather-card__fact-dot weather-card__fact-dot--humidity" />
<span>湿度 {{ weather.humidity }}%</span>
</div>
<div class="weather-card__fact">
<WeatherGlyph kind="wind" />
<span class="weather-card__fact-dot weather-card__fact-dot--wind" />
<span>风速 {{ weather.windSpeed }} km/h</span>
</div>
</div>
@@ -148,7 +144,7 @@ function forecastKind(day: ForecastDay) {
:class="['metric-pill', metric.accent]"
>
<div class="metric-pill__label">
<WeatherGlyph :kind="metric.icon" />
<span class="metric-pill__dot" />
<span>{{ metric.label }}</span>
</div>
<p class="metric-pill__value">{{ metric.value }}</p>
@@ -156,3 +152,213 @@ function forecastKind(day: ForecastDay) {
</div>
</section>
</template>
<style scoped>
.weather-card {
display: grid;
grid-template-rows: auto minmax(0, 1.15fr) minmax(0, 0.9fr) minmax(0, 1fr);
align-content: stretch;
height: 100%;
gap: 0.65rem;
padding: 1.05rem 1.1rem 0.92rem;
overflow: hidden;
}
.weather-card__heading {
display: flex;
justify-content: space-between;
align-items: flex-start;
}
.weather-card__title,
.weather-card__subtitle {
margin: 0;
}
.weather-card__title {
font-size: 2.2rem;
font-weight: 700;
color: #111111;
}
.weather-card__subtitle {
margin-top: 0.14rem;
font-size: 1.18rem;
line-height: 1.08;
color: #707070;
}
.weather-card__hero {
display: grid;
grid-template-columns: 1fr;
align-items: center;
gap: 0.8rem;
min-height: 0;
padding: 0.88rem 0.94rem;
border-radius: 1rem;
background: linear-gradient(180deg, #dfeaf8, #d7e4f6);
}
.weather-card__hero--placeholder {
justify-content: center;
min-height: 5.75rem;
color: #617288;
}
.weather-card__hero-main {
display: flex;
align-items: center;
gap: 0.92rem;
min-width: 0;
}
.weather-card__temperature {
font-size: 2.8rem;
line-height: 0.94;
color: #111111;
}
.weather-card__condition {
margin-top: 0.18rem;
font-size: 1.36rem;
line-height: 1.05;
color: #2c3641;
}
.weather-card__facts {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 0.28rem;
justify-items: start;
min-width: 0;
}
.weather-card__fact {
display: inline-flex;
align-items: flex-start;
gap: 0.45rem;
color: #617288;
font-size: 1.08rem;
line-height: 1.06;
min-width: 0;
}
.weather-card__fact-dot,
.metric-pill__dot {
width: 0.42rem;
height: 0.42rem;
border-radius: 50%;
background: currentColor;
flex: 0 0 auto;
}
.weather-card__fact-dot--humidity {
color: #5c84be;
}
.weather-card__fact-dot--wind {
color: #6c7e95;
}
.weather-card__forecast {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 0.46rem;
min-height: 0;
}
.forecast-pill {
display: grid;
justify-items: center;
align-content: center;
gap: 0.34rem;
min-height: 0;
padding: 0.68rem 0.28rem;
border-radius: 1rem;
background: #f8f7f6;
}
.forecast-pill__label,
.forecast-pill__temp {
margin: 0;
}
.forecast-pill__label {
font-size: 1rem;
line-height: 1;
color: #5a5a5a;
}
.forecast-pill__temp {
font-size: 1.24rem;
line-height: 1;
color: #1e1e1e;
}
.forecast-pill__temp span {
margin-left: 0.12rem;
font-size: 1rem;
color: #7a7a7a;
}
.weather-card__metrics {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
grid-auto-rows: 1fr;
gap: 0.46rem;
min-height: 0;
}
.metric-pill {
display: grid;
align-content: center;
gap: 0.3rem;
min-height: 0;
padding: 0.68rem 0.74rem;
border-radius: 0.95rem;
}
.metric-pill__label,
.metric-pill__value {
margin: 0;
}
.metric-pill__label {
display: inline-flex;
align-items: center;
gap: 0.4rem;
font-size: 0.88rem;
line-height: 1.06;
}
.metric-pill__value {
font-size: 1.34rem;
line-height: 1.02;
color: #1c1c1c;
}
.metric-pill--sunrise {
background: #fff1dd;
color: #cb6800;
}
.metric-pill--sunset {
background: #efe4ff;
color: #8e42e8;
}
.metric-pill--air {
background: #dbf5df;
color: #1f9451;
}
.metric-pill--visibility {
background: #d8f4fa;
color: #17779d;
}
.weather-card :deep(.glyph--large) {
width: 2.8rem;
height: 2.8rem;
}
</style>

View File

@@ -1,154 +1,35 @@
<script setup lang="ts">
import { computed } from 'vue';
type WeatherGlyphKind =
| 'clear'
| 'partly'
| 'cloudy'
| 'fog'
| 'rain'
| 'snow'
| 'storm'
| 'sunrise'
| 'sunset'
| 'air'
| 'visibility'
| 'humidity'
| 'wind';
import { weatherIconForKind, type WeatherIconKind } from '@/lib/icon-assets';
const props = defineProps<{
kind: WeatherGlyphKind;
kind: WeatherIconKind;
large?: boolean;
}>();
// 统一收细线宽和轮廓,让图标在墨水屏和小尺寸卡片里更精致、更稳定。
const strokeWidth = computed(() => (props.large ? 1.95 : 1.8));
const source = computed(() => weatherIconForKind(props.kind));
</script>
<template>
<svg
<img
:class="['glyph', { 'glyph--large': props.large }]"
viewBox="0 0 48 48"
fill="none"
stroke="currentColor"
stroke-linecap="round"
stroke-linejoin="round"
:stroke-width="strokeWidth"
:src="source"
alt=""
aria-hidden="true"
>
<g v-if="props.kind === 'clear'">
<circle cx="24" cy="24" r="6.5" />
<line x1="24" y1="8.5" x2="24" y2="12.5" />
<line x1="24" y1="35.5" x2="24" y2="39.5" />
<line x1="8.5" y1="24" x2="12.5" y2="24" />
<line x1="35.5" y1="24" x2="39.5" y2="24" />
<line x1="13.1" y1="13.1" x2="15.9" y2="15.9" />
<line x1="32.1" y1="32.1" x2="34.9" y2="34.9" />
<line x1="13.1" y1="34.9" x2="15.9" y2="32.1" />
<line x1="32.1" y1="15.9" x2="34.9" y2="13.1" />
</g>
<g v-else-if="props.kind === 'partly'">
<circle cx="16.5" cy="16.5" r="5" />
<line x1="16.5" y1="7.5" x2="16.5" y2="10.5" />
<line x1="9.2" y1="16.5" x2="12.2" y2="16.5" />
<line x1="11.4" y1="11.4" x2="13.6" y2="13.6" />
<line x1="21.6" y1="11.4" x2="19.4" y2="13.6" />
<path d="M15.5 31.5h15.8a5.7 5.7 0 1 0-.5-11.3 8.4 8.4 0 0 0-15.3-1.7 6.2 6.2 0 0 0 0 13Z" />
</g>
<g v-else-if="props.kind === 'cloudy'">
<path d="M13 31.5h18.8a6.3 6.3 0 1 0-.6-12.5 9.4 9.4 0 0 0-17.2-2.1A7 7 0 0 0 13 31.5Z" />
</g>
<g v-else-if="props.kind === 'fog'">
<path d="M13 25.5h18.8a6.3 6.3 0 1 0-.6-12.5 9.4 9.4 0 0 0-17.2-2.1A7 7 0 0 0 13 25.5Z" />
<path d="M11 31c2 0 3-1 5-1s3 1 5 1 3-1 5-1 3 1 5 1 3-1 5-1" />
<path d="M14 36c1.7 0 2.6-.8 4.3-.8s2.6.8 4.3.8 2.6-.8 4.3-.8 2.6.8 4.3.8" />
</g>
<g v-else-if="props.kind === 'rain'">
<path d="M13 26.5h18.8a6.3 6.3 0 1 0-.6-12.5 9.4 9.4 0 0 0-17.2-2.1A7 7 0 0 0 13 26.5Z" />
<path d="M16 31c0 1.9-1.2 3.4-2.7 3.4s-2.7-1.5-2.7-3.4c0-1.6 2.7-4.9 2.7-4.9S16 29.4 16 31Z" />
<path d="M25.4 34.2c0 1.8-1.1 3.2-2.4 3.2s-2.4-1.4-2.4-3.2c0-1.5 2.4-4.5 2.4-4.5s2.4 3 2.4 4.5Z" />
<path d="M34.8 31c0 1.9-1.2 3.4-2.7 3.4s-2.7-1.5-2.7-3.4c0-1.6 2.7-4.9 2.7-4.9s2.7 3.3 2.7 4.9Z" />
</g>
<g v-else-if="props.kind === 'snow'">
<path d="M13 26.5h18.8a6.3 6.3 0 1 0-.6-12.5 9.4 9.4 0 0 0-17.2-2.1A7 7 0 0 0 13 26.5Z" />
<g>
<line x1="16.5" y1="30.5" x2="16.5" y2="38.5" />
<line x1="13" y1="34.5" x2="20" y2="34.5" />
<line x1="13.8" y1="31.8" x2="19.2" y2="37.2" />
<line x1="19.2" y1="31.8" x2="13.8" y2="37.2" />
</g>
<g>
<line x1="31.5" y1="30.5" x2="31.5" y2="38.5" />
<line x1="28" y1="34.5" x2="35" y2="34.5" />
<line x1="28.8" y1="31.8" x2="34.2" y2="37.2" />
<line x1="34.2" y1="31.8" x2="28.8" y2="37.2" />
</g>
</g>
<g v-else-if="props.kind === 'storm'">
<path d="M13 26.5h18.8a6.3 6.3 0 1 0-.6-12.5 9.4 9.4 0 0 0-17.2-2.1A7 7 0 0 0 13 26.5Z" />
<path d="M24.5 29.5l-4 7h4.4l-2 8 7.6-10h-4.8l2.3-5Z" />
</g>
<g v-else-if="props.kind === 'sunrise'">
<line x1="10" y1="33" x2="38" y2="33" />
<path d="M16 33a8 8 0 0 1 16 0" />
<line x1="24" y1="10" x2="24" y2="16" />
<polyline points="21,13 24,10 27,13" />
<line x1="15" y1="26" x2="17.5" y2="23.5" />
<line x1="33" y1="26" x2="30.5" y2="23.5" />
</g>
<g v-else-if="props.kind === 'sunset'">
<line x1="10" y1="33" x2="38" y2="33" />
<path d="M16 33a8 8 0 0 1 16 0" />
<line x1="24" y1="10" x2="24" y2="16" />
<polyline points="21,13 24,16 27,13" />
<line x1="15" y1="26" x2="17.5" y2="28.5" />
<line x1="33" y1="26" x2="30.5" y2="28.5" />
</g>
<g v-else-if="props.kind === 'air'">
<path d="M10 17c4.2 0 4.2-3 8.4-3s4.2 3 8.4 3 4.2-3 8.4-3" />
<path d="M13 24c3.8 0 3.8-2.5 7.6-2.5s3.8 2.5 7.6 2.5 3.8-2.5 7.6-2.5" />
<path d="M10 31c4.2 0 4.2-3 8.4-3s4.2 3 8.4 3 4.2-3 8.4-3" />
</g>
<g v-else-if="props.kind === 'visibility'">
<path d="M6 24c3.1-4.8 8.8-8 18-8s14.9 3.2 18 8c-3.1 4.8-8.8 8-18 8S9.1 28.8 6 24Z" />
<circle cx="24" cy="24" r="4.8" />
<circle cx="24" cy="24" r="1.5" fill="currentColor" stroke="none" />
</g>
<g v-else-if="props.kind === 'humidity'">
<path d="M24 10C20.5 15 16 20.4 16 27a8 8 0 0 0 16 0c0-6.6-4.5-12-8-17Z" />
<path d="M24 30.5a4.6 4.6 0 0 0 4.4-3.1" />
</g>
<g v-else-if="props.kind === 'wind'">
<path d="M10 17h17c3.3 0 5.5-2 5.5-4.8 0-2.4-1.9-4.2-4.4-4.2-2.1 0-3.7 1.2-4.4 3" />
<path d="M10 25h22c2.8 0 4.8 1.8 4.8 4.2 0 2.3-1.8 4.1-4.1 4.1-1.9 0-3.4-.9-4.2-2.4" />
<path d="M10 33h11c2.5 0 4.3 1.6 4.3 3.8 0 2.1-1.7 3.7-3.9 3.7-1.6 0-2.9-.7-3.7-2" />
</g>
</svg>
/>
</template>
<style scoped>
.glyph {
width: 1.12rem;
height: 1.12rem;
width: 1.25rem;
height: 1.25rem;
flex-shrink: 0;
color: currentColor;
overflow: visible;
object-fit: contain;
}
.glyph--large {
width: 2.7rem;
height: 2.7rem;
width: 2.5rem;
height: 2.5rem;
}
</style>

10
calendar/src/env.d.ts vendored
View File

@@ -9,3 +9,13 @@ declare module '*.md?raw' {
const content: string;
export default content;
}
declare module '*.svg' {
const content: string;
export default content;
}
declare module '*.png' {
const content: string;
export default content;
}

51
calendar/src/lib/clock.ts Normal file
View File

@@ -0,0 +1,51 @@
import clockFaceAsset from '../../../assets/clock-face.png';
import hourHandAsset from '../../../assets/hour-hand.png';
import minuteHandAsset from '../../../assets/minite-hand.png';
export interface ClockHandSpec {
src: string;
sourceWidth: number;
sourceHeight: number;
pivotX: number;
pivotY: number;
}
export interface ClockState {
hourAngle: number;
minuteAngle: number;
digitalLabel: string;
}
// 这组尺寸直接来自仓库里的 PNG 素材Kindle 侧也复用同一套基准。
export const CLOCK_FACE_SOURCE_SIZE = 431;
export const CLOCK_RENDER_SIZE = 220;
// 锚点按 Figma annotation 的中心点拟合到现有 PNG保证网页预览与 Kindle 贴图一致。
export const HOUR_HAND_SPEC: ClockHandSpec = {
src: hourHandAsset,
sourceWidth: 32,
sourceHeight: 205,
pivotX: 13,
pivotY: 138,
};
export const MINUTE_HAND_SPEC: ClockHandSpec = {
src: minuteHandAsset,
sourceWidth: 32,
sourceHeight: 288,
pivotX: 15,
pivotY: 203,
};
export const CLOCK_FACE_ASSET = clockFaceAsset;
export function buildClockState(date: Date): ClockState {
const hour = date.getHours() % 12;
const minute = date.getMinutes();
return {
hourAngle: hour * 30 + minute * 0.5,
minuteAngle: minute * 6,
digitalLabel: `${String(date.getHours()).padStart(2, '0')}:${String(minute).padStart(2, '0')}`,
};
}

View File

@@ -0,0 +1,14 @@
export type DashboardMode = 'full' | 'background' | 'clock-face';
const VALID_MODES = new Set<DashboardMode>(['full', 'background', 'clock-face']);
export function resolveDashboardMode(search: string): DashboardMode {
const params = new URLSearchParams(search);
const mode = params.get('mode');
if (mode && VALID_MODES.has(mode as DashboardMode)) {
return mode as DashboardMode;
}
return 'full';
}

View File

@@ -0,0 +1,39 @@
import bookIcon from '../../../assets/书摘.svg';
import cloudyIcon from '../../../assets/多云.svg';
import heavyRainIcon from '../../../assets/大雨.svg';
import snowIcon from '../../../assets/大雪.svg';
import lightRainIcon from '../../../assets/小雨.svg';
import nightIcon from '../../../assets/晚上.svg';
import clearIcon from '../../../assets/晴天.svg';
import sleetIcon from '../../../assets/雨夹雪.svg';
export type WeatherIconKind =
| 'clear'
| 'partly'
| 'cloudy'
| 'fog'
| 'rain'
| 'heavy-rain'
| 'snow'
| 'storm'
| 'sleet'
| 'night';
const WEATHER_ICON_MAP: Record<WeatherIconKind, string> = {
clear: clearIcon,
partly: cloudyIcon,
cloudy: cloudyIcon,
fog: cloudyIcon,
rain: lightRainIcon,
'heavy-rain': heavyRainIcon,
snow: snowIcon,
storm: heavyRainIcon,
sleet: sleetIcon,
night: nightIcon,
};
export function weatherIconForKind(kind: WeatherIconKind) {
return WEATHER_ICON_MAP[kind];
}
export const QUOTE_ICON_ASSET = bookIcon;

View File

@@ -4,11 +4,11 @@
'PingFang SC',
'Noto Sans SC',
sans-serif;
color: #0f172a;
color: #111111;
background:
radial-gradient(circle at 18% 12%, rgba(255, 255, 255, 0.88), transparent 30%),
radial-gradient(circle at 82% 18%, rgba(241, 238, 232, 0.92), transparent 24%),
linear-gradient(160deg, #f4f1eb 0%, #ebe5dc 52%, #e4ddd4 100%);
radial-gradient(circle at 12% 10%, rgba(255, 255, 255, 0.9), transparent 26%),
radial-gradient(circle at 88% 18%, rgba(255, 255, 255, 0.74), transparent 22%),
linear-gradient(150deg, #fbe9e8 0%, #f2d7d7 48%, #eed6d5 100%);
font-synthesis: none;
text-rendering: optimizeLegibility;
-webkit-font-smoothing: antialiased;
@@ -30,419 +30,74 @@ body {
min-height: 100vh;
}
img {
display: block;
}
.page-shell {
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
padding: 1.5rem;
padding: 0.75rem;
}
.page-shell--clock-face {
background: #f5f2ef;
}
.dashboard-frame {
width: min(100%, 1448px);
aspect-ratio: 1448 / 1072;
padding: clamp(1.2rem, 1vw + 0.9rem, 2rem);
border-radius: 2rem;
width: 100%;
aspect-ratio: 1024 / 600;
padding: 1rem;
border-radius: 1.9rem;
background:
linear-gradient(180deg, rgba(255, 255, 255, 0.38), rgba(255, 255, 255, 0.18)),
rgba(255, 255, 255, 0.14);
linear-gradient(180deg, rgba(255, 255, 255, 0.42), rgba(255, 255, 255, 0.18)),
rgba(255, 255, 255, 0.16);
box-shadow:
inset 0 1px 0 rgba(255, 255, 255, 0.55),
0 38px 72px rgba(64, 52, 38, 0.14);
inset 0 1px 0 rgba(255, 255, 255, 0.62),
0 24px 46px rgba(99, 64, 66, 0.18);
backdrop-filter: blur(14px);
}
.dashboard-grid {
display: grid;
grid-template-columns: 1.08fr 1fr;
grid-template-rows: auto auto;
gap: clamp(1rem, 1vw + 0.75rem, 2rem);
grid-template-columns: minmax(0, 1fr) minmax(0, 0.95fr);
grid-template-rows: minmax(0, 1fr) 72px;
gap: 1rem;
height: 100%;
align-content: start;
align-items: stretch;
}
.card {
border-radius: 1.6rem;
min-height: 0;
border-radius: 1.75rem;
background: rgba(255, 255, 255, 0.94);
box-shadow:
0 20px 36px rgba(72, 58, 44, 0.12),
inset 0 1px 0 rgba(255, 255, 255, 0.65);
border: 1px solid rgba(70, 58, 46, 0.08);
0 16px 34px rgba(92, 67, 60, 0.18),
inset 0 1px 0 rgba(255, 255, 255, 0.72);
border: 1px solid rgba(117, 80, 76, 0.08);
}
.calendar-card,
.weather-card {
padding: clamp(1.4rem, 1.2vw + 1rem, 2rem);
min-height: 0;
height: 100%;
}
.calendar-card {
display: grid;
grid-template-rows: auto 1fr;
gap: 0.8rem;
}
.calendar-card__header {
display: flex;
align-items: center;
gap: 1.6rem;
}
.calendar-card__day {
font-family:
'Iowan Old Style',
'Baskerville',
serif;
font-size: clamp(4.4rem, 5vw, 7rem);
line-height: 0.95;
letter-spacing: -0.08em;
color: #101010;
}
.calendar-card__meta {
display: grid;
gap: 0.65rem;
}
.calendar-card__date-line,
.calendar-card__lunar {
display: flex;
gap: 1rem;
align-items: baseline;
margin: 0;
font-size: clamp(1rem, 0.7vw + 0.7rem, 1.55rem);
}
.calendar-card__weekday {
color: #556274;
}
.calendar-card__lunar {
color: #5f4b32;
flex-wrap: wrap;
}
.calendar-card__badges {
display: flex;
flex-wrap: wrap;
gap: 0.45rem;
}
.calendar-card__badges--inline {
display: inline-flex;
align-items: center;
margin-left: 0.1rem;
}
.calendar-card__badge {
display: inline-flex;
align-items: center;
min-height: 1.6rem;
padding: 0.2rem 0.55rem;
border-radius: 999px;
border: 1px solid currentColor;
font-size: 0.76rem;
line-height: 1;
}
.calendar-card__badge--holiday {
color: #2f2b26;
background: #ece5db;
}
.calendar-card__badge--workday {
color: #68593f;
background: #f2eadb;
}
.calendar-card__badge--festival {
color: #4f4436;
background: #f5f0e8;
}
.calendar-card__badge--term {
color: #3a4a4a;
background: #e9efef;
}
.calendar-card__grid {
display: grid;
grid-template-columns: repeat(7, minmax(0, 1fr));
grid-template-rows: auto;
grid-auto-rows: minmax(0, 1fr);
gap: 0.35rem 0.3rem;
align-content: start;
height: 100%;
}
.calendar-card__week-label {
text-align: center;
color: #798395;
font-size: 0.92rem;
padding-bottom: 0.45rem;
}
.calendar-card__cell {
min-height: 0;
.clock-face-stage {
display: grid;
place-items: center;
align-content: center;
padding: 0.2rem 0;
border-radius: 0.8rem;
color: #304153;
transition: background-color 150ms ease;
width: min(100%, 320px);
aspect-ratio: 1;
}
.calendar-card__cell--muted {
color: #bac4d2;
}
.calendar-card__cell--today {
background: linear-gradient(180deg, #303640 0%, #1f252d 100%);
color: #ffffff;
box-shadow: 0 14px 24px rgba(31, 37, 45, 0.24);
}
.calendar-card__cell--holiday .calendar-card__solar,
.calendar-card__cell--holiday .calendar-card__lunar-day {
color: #6a4a1c;
}
.calendar-card__solar {
font-size: 1.3rem;
line-height: 1.1;
}
.calendar-card__lunar-day {
font-size: 0.68rem;
line-height: 1.2;
opacity: 0.82;
}
.weather-card {
display: flex;
flex-direction: column;
justify-content: space-between;
gap: 0.8rem;
}
.weather-card__heading {
display: flex;
justify-content: space-between;
align-items: flex-start;
gap: 1rem;
}
.weather-card__title {
margin: 0;
font-size: clamp(1.5rem, 1vw + 1rem, 2rem);
font-weight: 700;
}
.weather-card__subtitle {
margin: 0.35rem 0 0;
color: #778396;
font-size: 0.95rem;
}
.weather-card__hero {
display: flex;
justify-content: space-between;
align-items: center;
gap: 1rem;
padding: 1.05rem 1.2rem;
border-radius: 1.2rem;
background: linear-gradient(180deg, #ece8e1 0%, #e2ddd4 100%);
}
.weather-card__hero--placeholder {
justify-content: center;
color: #64748b;
}
.weather-card__hero-main {
display: flex;
align-items: center;
gap: 1rem;
}
.weather-card__temperature {
font-size: clamp(2.2rem, 1.2vw + 1.6rem, 3.2rem);
line-height: 1;
font-family:
'Iowan Old Style',
'Baskerville',
serif;
}
.weather-card__condition {
margin-top: 0.35rem;
font-size: 1.15rem;
color: #243041;
}
.weather-card__facts {
display: grid;
gap: 0.45rem;
color: #475569;
}
.weather-card__fact {
display: flex;
align-items: center;
gap: 0.45rem;
font-size: 0.98rem;
}
.weather-card__forecast {
display: grid;
grid-template-columns: repeat(5, minmax(0, 1fr));
gap: 0.65rem;
align-items: stretch;
}
.forecast-pill {
display: grid;
justify-items: center;
align-content: center;
gap: 0.45rem;
padding: 0.8rem 0.55rem;
border-radius: 1rem;
background: #f7f4ef;
border: 1px solid rgba(82, 70, 56, 0.08);
height: 100%;
}
.forecast-pill__label {
margin: 0;
color: #556274;
font-size: 0.9rem;
line-height: 1.1;
}
.forecast-pill__temp {
margin: 0;
font-size: 1rem;
font-weight: 700;
}
.forecast-pill__temp span {
margin-left: 0.15rem;
color: #7d8798;
font-weight: 500;
}
.weather-card__metrics {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 0.65rem;
}
.metric-pill {
display: grid;
align-content: center;
gap: 0.45rem;
padding: 0.85rem 0.9rem 0.9rem;
border-radius: 1rem;
}
.metric-pill__label {
display: flex;
align-items: center;
gap: 0.45rem;
font-size: 0.88rem;
}
.metric-pill__value {
margin: 0;
font-size: 1.25rem;
font-weight: 700;
}
.metric--sunrise {
background: linear-gradient(150deg, #f3eee5, #ebe3d6);
color: #5c4631;
}
.metric--sunset {
background: linear-gradient(150deg, #efebe6, #e5dfd6);
color: #4f463d;
}
.metric--air {
background: linear-gradient(150deg, #eef0eb, #e3e7de);
color: #354338;
}
.metric--visibility {
background: linear-gradient(150deg, #eef1f0, #e2e5e4);
color: #3b4a4d;
}
.quote-card {
grid-column: 1 / -1;
display: grid;
gap: 1.1rem;
padding: clamp(1.5rem, 1.2vw + 1rem, 2rem);
background: linear-gradient(168deg, rgba(249, 245, 236, 0.98), rgba(244, 239, 229, 0.94));
}
.quote-card__content {
margin: 0;
color: #1e293b;
line-height: 1.65;
font-family:
'Iowan Old Style',
'Baskerville',
'Noto Serif SC',
serif;
}
@media (max-width: 1080px) {
.page-shell {
padding: 0.9rem;
}
@media (max-width: 860px) {
.dashboard-frame {
aspect-ratio: auto;
width: min(100%, 1448px);
}
.dashboard-grid {
grid-template-columns: 1fr;
grid-template-rows: auto;
grid-template-rows: none;
}
.quote-card {
grid-column: auto;
}
.weather-card__forecast,
.weather-card__metrics {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
}
@media (max-width: 720px) {
.calendar-card__header,
.weather-card__heading,
.weather-card__hero {
grid-template-columns: 1fr;
display: grid;
}
.calendar-card__header {
gap: 1rem;
}
.weather-card__forecast,
.weather-card__metrics {
grid-template-columns: 1fr 1fr;
}
.quote-card__content {
font-size: 1.45rem !important;
.page-shell {
align-items: stretch;
}
}